@vidit19sharma/opencode-cursor-auth 0.3.3 → 0.3.4

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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4
4
 
5
+ ## 0.3.4 — 2026-10-16
6
+
7
+ ### Fixed
8
+
9
+ - A run waiting for a tool result held a concurrency slot until its continuation or the 10-minute timer, so a Cursor parent parked on a Cursor sub-agent could deadlock at the cap and Esc pinned a slot for 10 minutes. Parked runs now release their slot; continuations re-acquire one through a priority queue. Esc (session idle) releases that session's parked runs immediately; a second continuation for the same tool call gets 409 `continuation_in_progress`.
10
+ - A parallel tool call that arrived after the 300 ms batch window was never delivered and the run hung until the idle timer. It is now delivered on the next segment.
11
+ - A tool result for a run that is gone (Esc, idle release, sleep, restart) returned 400 `tool_call_expired`. It now replays the conversation into a fresh agent, with tool results attributed by name and the earlier calls marked as already made.
12
+ - After a tool loop, opencode's context meter and auto-compaction saw Cursor's summed usage for every model call in the run (2–4× the real context). The bridge now reports an upper-bound estimate of the last call's prompt, remembering the previous turn's prompt per agent, and keeps `cached_tokens` ≤ `prompt_tokens` (Cursor's `inputTokens` already includes cache).
13
+ - Errors are classified by Cursor's backend error code before the SDK class or message: `BAD_API_KEY` / `BAD_USER_API_KEY` → 401, `MODEL_BLOCKED` → 403, and a spent plan (`FREE_USER_USAGE_LIMIT` / `PRO_USER_USAGE_LIMIT`) → new 402 `insufficient_quota`, which is never retried. A 429 carries `retry-after` when Cursor's message names a wait; aborts are never retried. Local system errors (e.g. `EACCES` writing `AGENTS.md`) are 502 and never mark a model blocked. (from #1 by @Dakuaisu)
14
+ - An unwritable or full cache dir made `sessions.json` writes throw, failing turns and throwing from the tool-flush timer; session persistence is now best effort and atomic (temp file, fsync, rename), and workspace removal no longer throws. A failed `AGENTS.md` write now fails the request normally instead of leaking the concurrency slot. A stale model catalog is served immediately and refreshed in the background; a first run with no cache waits at most 15 s. (from #1 by @Dakuaisu)
15
+ - opencode aborted requests after 5 minutes without response headers or between chunks, which a queued request, silent reasoning or a large tool argument could exceed. `headerTimeout` and `chunkTimeout` now default to 30 minutes for the cursor provider (user-set `provider.cursor.options` win). (from #1 by @Dakuaisu)
16
+ - Session resume after a tool-using turn never worked for `cursor/claude-*` models: opencode's `cache_control` tags changed how tool calls serialized a turn later, so every such turn replayed the whole history into a new agent. Tool calls now hash by id, name and arguments. Existing session keys change once. (from #1 by @Dakuaisu)
17
+ - Cursor agents were never deleted: every conversation left an agent plus its store and transcript under `~/.cursor/projects/<workspace>`. When a workspace is released, the bridge now deletes the agent (after any cancel settles) and removes Cursor's project dir along with the workspace; deletion is limited to the bridge's own `opencode-cursor-XXXXXX` temp workspaces. (from #1 by @Dakuaisu)
18
+
5
19
  ## 0.3.3 — 2026-10-16
6
20
 
7
21
  ### Added
package/README.md CHANGED
@@ -14,7 +14,7 @@ The plugin bridges opencode's OpenAI-style chat requests to the official [`@curs
14
14
  - **System prompts** — delivered via `AGENTS.md` in a per-session workspace (the `--system-prompt` flag is server-gated by Cursor)
15
15
  - **Dynamic model list with variants** — fetched live from `Cursor.models.list()`, cached 24h; one id per model, with every reasoning-effort / thinking / fast / context variant on opencode's Ctrl+T variant picker
16
16
  - **Context limits** — each model publishes the context window of Cursor's default variant, 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
17
+ - **Real error statuses** — 400 / 401 / 402 / 403 / 429 / 502 instead of a 200 stream carrying an error
18
18
  - **Cancellation** — Esc or a sub-agent timeout cancels the Cursor run and closes the agent
19
19
  - **Concurrency cap and retry** — at most 4 Cursor runs at once (configurable); transient failures retried before the first byte
20
20
  - **Usage** — prompt, completion, cached-read, and reasoning tokens reported to opencode
@@ -117,7 +117,7 @@ opencode models | grep cursor/
117
117
  - **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.
118
118
  - **Offline / logged out**: the stale cache is used if present, otherwise only `cursor/auto`.
119
119
 
120
- The catalog is cached for 24h at `~/.cache/opencode-cursor-auth/models.json`; delete it to force a refresh.
120
+ The catalog is cached for 24h at `~/.cache/opencode-cursor-auth/models.json`; delete it to force a refresh. An expired catalog is still used at startup and refreshed in the background, so new models appear on the next start; only a first run with no cache waits for Cursor (at most 15 s).
121
121
 
122
122
  ### Migrating from 0.2.0
123
123
 
@@ -131,12 +131,14 @@ The models cache is refetched once on upgrade.
131
131
  | --- | --- | --- |
132
132
  | `OPENCODE_CURSOR_MAX_ACTIVE` | `4` | Max Cursor runs active at once; extra requests queue FIFO |
133
133
  | `CURSOR_API_KEY` | unset | Use this key instead of the stored SDK key |
134
+ | `provider.cursor.options.headerTimeout` / `chunkTimeout` (opencode config) | 30 min | How long opencode waits for a reply to start / between chunks before aborting (opencode's own default is 5 min) |
134
135
 
135
136
  | Path | Contents |
136
137
  | --- | --- |
137
138
  | `~/.cursor/sdk/auth.json` | 90-day SDK key from browser login (written by `@cursor/sdk`) |
138
139
  | `~/.cache/opencode-cursor-auth/models.json` | Model catalog, 24h TTL, blocked-model flags |
139
140
  | `~/.cache/opencode-cursor-auth/sessions.json` | Conversation → Cursor agent map, 7-day TTL |
141
+ | `~/.cursor/projects/<workspace>` | Cursor's agent store and transcript for each bridge workspace (written by `@cursor/sdk`); removed with the workspace |
140
142
 
141
143
  ## How it works
142
144
 
@@ -148,7 +150,7 @@ opencode ──(OpenAI chat JSON)──> plugin fetch() ──> @cursor/sdk Agen
148
150
  - opencode calls the provider's `fetch` — the plugin intercepts it in-process; `http://cursor.local/v1` is a dummy URL that never resolves.
149
151
  - 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.
150
152
  - 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`.
151
- - 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.
153
+ - System messages are written to `AGENTS.md` in a per-session temp workspace and loaded via Cursor's project rules (`settingSources: ["project"]`). Once no session references a workspace, the bridge deletes its Cursor agent (`Agent.delete`), Cursor's store and transcript for it under `~/.cursor/projects`, and the workspace itself.
152
154
  - The selected variant reaches the plugin as `providerOptions.cursor`; `@ai-sdk/openai-compatible` puts it in the request body as top-level `cursor_params: [{ id, value }, …]`, which the bridge validates and maps to Cursor `ModelSelection.params`.
153
155
  - 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.
154
156
  - The HTTP reply opens on the first text or thinking delta, so failures before any content become real HTTP errors.
@@ -160,8 +162,9 @@ opencode ──(OpenAI chat JSON)──> plugin fetch() ──> @cursor/sdk Agen
160
162
  | --- | --- |
161
163
  | 400 | Unknown model or bad argument (Cursor's message), unknown variant param; also an expired tool-call continuation |
162
164
  | 401 | SDK key missing/expired, or Cursor rejects the key |
165
+ | 402 | Plan usage limit reached (`insufficient_quota`); not retried, waiting won't help |
163
166
  | 403 | Model blocked for your account (`permission_error`) |
164
- | 429 | Cursor rate limit |
167
+ | 429 | Cursor rate limit; carries `retry-after` when Cursor's message names a wait |
165
168
  | 502 | Anything else |
166
169
 
167
170
  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]`.
@@ -177,7 +180,7 @@ bun run test:cli # exercises the npx setup command against temp config dirs
177
180
  bun run smoke # packs the plugin and loads it in a pristine HOME (startup, auth login, first request before login)
178
181
  ```
179
182
 
180
- 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.
183
+ The e2e suite (Tests 0-21) covers 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, blocked models, parked-run slot and expiry handling, and token-usage estimation for tool loops.
181
184
 
182
185
  ## Known limitations
183
186
 
package/dist/bridge.d.ts CHANGED
@@ -30,10 +30,30 @@ type OaBody = {
30
30
  stream?: boolean;
31
31
  cursor_params?: unknown;
32
32
  };
33
+ type Usage = {
34
+ prompt_tokens: number;
35
+ completion_tokens: number;
36
+ total_tokens: number;
37
+ prompt_tokens_details?: {
38
+ cached_tokens: number;
39
+ };
40
+ completion_tokens_details?: {
41
+ reasoning_tokens: number;
42
+ };
43
+ };
33
44
  export declare function isTransient(err: unknown): boolean;
34
45
  export declare function withRetry<T>(fn: () => Promise<T>, signal?: AbortSignal, baseDelayMs?: number, retryable?: typeof isTransient): Promise<T>;
46
+ export declare function _setBatchSettleMs(ms: number): void;
35
47
  export declare function _setMaxActive(n: number): void;
36
48
  export declare function errorResponse(err: unknown, status?: number): Response;
49
+ export declare function mapUsage(u?: {
50
+ inputTokens?: number;
51
+ outputTokens?: number;
52
+ cacheReadTokens?: number;
53
+ reasoningTokens?: number;
54
+ }, n?: number, L?: number): Usage | undefined;
55
+ /** opencode session went idle (Esc during a tool call): drop its parked runs now, not in 10 min. */
56
+ export declare function releaseParked(scope: string): void;
37
57
  export declare function _resetForTest(): void;
38
58
  export declare function _failAfterFirstDelta(message: string | null): void;
39
59
  export declare function _stats(): {
package/dist/bridge.js CHANGED
@@ -10,10 +10,10 @@
10
10
  */
11
11
  import { Agent, AuthenticationError, ConfigurationError, NetworkError, RateLimitError } from "@cursor/sdk";
12
12
  import { createHash, randomUUID } from "node:crypto";
13
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
13
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
14
14
  import { mkdtemp, readFile, writeFile } from "node:fs/promises";
15
15
  import { homedir, tmpdir } from "node:os";
16
- import { join } from "node:path";
16
+ import { basename, dirname, join, resolve } from "node:path";
17
17
  import { markBlocked, resolveVariant } from "./models.js";
18
18
  // @cursor/sdk has no log-level switch: it prints "HH:MM:SS.mmm INFO ..." through
19
19
  // whatever console.* is installed at call time, ANSI-coloured when stdout is a
@@ -44,15 +44,27 @@ function textOf(content) {
44
44
  return "";
45
45
  return content.map((p) => p.text ?? "").join("");
46
46
  }
47
+ // err.code is Cursor's backend error name (MODEL_BLOCKED, PRO_USER_USAGE_LIMIT, ...) when the SDK has
48
+ // one. The SDK files a bad key under ConfigurationError and a spent plan under RateLimitError, so the
49
+ // code decides those before the class does.
50
+ const KEY_CODES = new Set(["BAD_API_KEY", "BAD_USER_API_KEY", "unauthenticated"]);
51
+ const USAGE_LIMIT_CODES = new Set(["FREE_USER_USAGE_LIMIT", "PRO_USER_USAGE_LIMIT"]);
47
52
  function statusFor(err) {
48
53
  const e = (typeof err === "object" && err !== null ? err : {});
54
+ // a local failure (EACCES writing AGENTS.md says "permission denied") is not Cursor's verdict on the
55
+ // model, and a 403 would mark the model blocked
56
+ if (typeof e.syscall === "string")
57
+ return 502;
49
58
  const msg = String(e.message ?? err ?? "");
50
59
  const code = typeof e.code === "string" ? e.code : "";
51
- if (err instanceof AuthenticationError || e.status === 401 || code === "unauthenticated" || /invalid user api key/i.test(msg))
60
+ if (err instanceof AuthenticationError || e.status === 401 || KEY_CODES.has(code) || /invalid user api key/i.test(msg))
52
61
  return 401;
62
+ // 402, not 429: opencode retries a 429, and waiting does not refill a plan's included usage
63
+ if (USAGE_LIMIT_CODES.has(code))
64
+ return 402;
53
65
  if (err instanceof RateLimitError || e.status === 429 || code === "resource_exhausted" || /rate limit/i.test(msg))
54
66
  return 429;
55
- if (code === "permission_denied" || /model blocked|permission denied|forbidden/i.test(msg))
67
+ if (code === "MODEL_BLOCKED" || code === "permission_denied" || /model blocked|permission denied|forbidden/i.test(msg))
56
68
  return 403;
57
69
  if (err instanceof ConfigurationError || code === "invalid_argument" || code === "not_found" || /cannot use this model|unknown model/i.test(msg))
58
70
  return 400;
@@ -61,9 +73,19 @@ function statusFor(err) {
61
73
  const ERROR_TYPE = {
62
74
  400: "invalid_request_error",
63
75
  401: "authentication_error",
76
+ 402: "insufficient_quota",
64
77
  403: "permission_error",
65
78
  429: "rate_limit_error",
66
79
  };
80
+ // "try again in 30 seconds" / "resets in 2h": tell opencode how long to wait instead of letting it guess
81
+ function retryAfterSeconds(msg) {
82
+ const m = msg.match(/\b(?:in|after)\s+(\d+(?:\.\d+)?)\s*(ms|s|secs?|seconds?|m|mins?|minutes?|h|hrs?|hours?)\b/i);
83
+ if (!m)
84
+ return undefined;
85
+ const unit = m[2].toLowerCase();
86
+ const factor = unit === "ms" ? 0.001 : unit.startsWith("s") ? 1 : unit.startsWith("m") ? 60 : 3600;
87
+ return Math.max(1, Math.ceil(Number(m[1]) * factor));
88
+ }
67
89
  function errorBody(err, status = statusFor(err)) {
68
90
  const e = (typeof err === "object" && err !== null ? err : {});
69
91
  return {
@@ -78,14 +100,16 @@ const abortError = () => new DOMException("aborted", "AbortError");
78
100
  const isAbort = (err) => err?.name === "AbortError";
79
101
  export function isTransient(err) {
80
102
  const e = (typeof err === "object" && err !== null ? err : {});
81
- if (e.name === "AbortError")
103
+ // the SDK marks aborts isRetryable:true; an abort is never worth retrying
104
+ if (e.name === "AbortError" || e.code === "ABORT_ERR")
82
105
  return false;
83
106
  const status = statusFor(err);
84
107
  if (status === 429)
85
108
  return true;
86
- if (status === 400 || status === 401 || status === 403 || e.status === 404)
109
+ if (status === 400 || status === 401 || status === 402 || status === 403 || e.status === 404)
87
110
  return false;
88
- if (err instanceof NetworkError || (typeof e.status === "number" && e.status >= 500))
111
+ // isRetryable is the backend's own flag; the SDK defaults it to false when none was sent, so only true counts
112
+ if (e.isRetryable === true || err instanceof NetworkError || (typeof e.status === "number" && e.status >= 500))
89
113
  return true;
90
114
  return /unavailable|deadline_exceeded|ECONNRESET|ETIMEDOUT|fetch failed/i.test(`${e.code ?? ""} ${e.message ?? ""}`);
91
115
  }
@@ -109,28 +133,31 @@ export async function withRetry(fn, signal, baseDelayMs = 1000, retryable = isTr
109
133
  }
110
134
  }
111
135
  }
112
- // ponytail: global semaphore, slot held across parked tool loops; per-session fairness if it ever matters
136
+ // ponytail: global semaphore over generating segments only (a parked run gives its slot back, a
137
+ // continuation re-acquires ahead of new turns); per-session fairness if it ever matters
113
138
  let maxActive = Number(process.env.OPENCODE_CURSOR_MAX_ACTIVE) || 4;
114
139
  let active = 0;
115
140
  const waiters = [];
141
+ const priorityWaiters = [];
116
142
  function pump() {
117
- while (active < maxActive && waiters.length) {
143
+ while (active < maxActive && (priorityWaiters.length || waiters.length)) {
118
144
  active++;
119
- waiters.shift()();
145
+ (priorityWaiters.shift() ?? waiters.shift())();
120
146
  }
121
147
  }
122
- function acquireSlot(signal) {
148
+ function acquireSlot(signal, priority = false) {
123
149
  if (signal?.aborted)
124
150
  return Promise.reject(abortError());
125
- if (active < maxActive && !waiters.length) {
151
+ const queue = priority ? priorityWaiters : waiters;
152
+ if (active < maxActive && !priorityWaiters.length && (priority || !waiters.length)) {
126
153
  active++;
127
154
  return Promise.resolve();
128
155
  }
129
156
  return new Promise((resolve, reject) => {
130
157
  const onAbort = () => {
131
- const i = waiters.indexOf(go);
158
+ const i = queue.indexOf(go);
132
159
  if (i >= 0)
133
- waiters.splice(i, 1);
160
+ queue.splice(i, 1);
134
161
  reject(abortError());
135
162
  };
136
163
  const go = () => {
@@ -138,19 +165,24 @@ function acquireSlot(signal) {
138
165
  resolve();
139
166
  };
140
167
  signal?.addEventListener("abort", onAbort, { once: true });
141
- waiters.push(go);
168
+ queue.push(go);
142
169
  });
143
170
  }
144
171
  function releaseSlot() {
145
172
  active--;
146
173
  pump();
147
174
  }
175
+ export function _setBatchSettleMs(ms) {
176
+ batchSettleMs = ms;
177
+ }
148
178
  export function _setMaxActive(n) {
149
179
  maxActive = n;
150
180
  pump();
151
181
  }
152
182
  export function errorResponse(err, status = statusFor(err)) {
153
- return Response.json(errorBody(err, status), { status });
183
+ const body = errorBody(err, status);
184
+ const wait = status === 429 ? retryAfterSeconds(body.error.message) : undefined;
185
+ return Response.json(body, { status, ...(wait ? { headers: { "retry-after": String(wait) } } : {}) });
154
186
  }
155
187
  function chunk(id, model, delta, finish, usage) {
156
188
  return {
@@ -276,7 +308,7 @@ function deferred() {
276
308
  });
277
309
  return { promise, resolve, reject };
278
310
  }
279
- const BATCH_SETTLE_MS = 300;
311
+ let batchSettleMs = 300;
280
312
  const TOOL_RESULT_TIMEOUT_MS = 10 * 60 * 1000;
281
313
  const SESSION_TTL_MS = 30 * 60 * 1000;
282
314
  const DISK_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
@@ -290,9 +322,23 @@ const heldByAgent = new Map();
290
322
  const agentByToolCallId = new Map();
291
323
  let diskSessions = new Map();
292
324
  let sessionsLoaded = false;
325
+ // Where @cursor/sdk keeps a workspace's agent store and transcripts. The slug mirrors the SDK's
326
+ // utils/workspace-paths; transcripts honour CURSOR_DATA_DIR (used untrimmed, as the SDK does) while the
327
+ // agent store always lives under ~/.cursor. Only ever called for our own tmp workspaces (releaseCwd guard).
328
+ function cursorProjectDirs(cwd) {
329
+ const slug = cwd.replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
330
+ const dataDir = process.env.CURSOR_DATA_DIR;
331
+ const home = join(homedir(), ".cursor");
332
+ return [...new Set([join(dataDir?.trim() ? dataDir : home, "projects", slug), join(home, "projects", slug)])];
333
+ }
334
+ // a workspace this process could have made with mkdtemp(TMP_CWD_PREFIX), nothing else
335
+ const isOwnWorkspace = (cwd) => resolve(cwd) === cwd && dirname(cwd) === tmpdir() && /^opencode-cursor-[A-Za-z0-9]{6}$/.test(basename(cwd));
293
336
  // ponytail: no orphan sweep, other opencode instances may own those dirs
294
- function releaseCwd(cwd) {
295
- if (!cwd.startsWith(TMP_CWD_PREFIX))
337
+ // Once nothing references a workspace, nothing can resume its agent either: delete the agent (after
338
+ // its cancel settles, the SDK refuses while a run is live), Cursor's store and transcript for it, and
339
+ // the workspace.
340
+ function releaseCwd(cwd, agentId, settled) {
341
+ if (!isOwnWorkspace(cwd))
296
342
  return;
297
343
  for (const v of sessions.values())
298
344
  if (v.cwd === cwd)
@@ -303,12 +349,23 @@ function releaseCwd(cwd) {
303
349
  for (const h of heldByAgent.values())
304
350
  if (h.cwd === cwd)
305
351
  return;
306
- rmSync(cwd, { recursive: true, force: true });
352
+ void Promise.resolve(settled)
353
+ .then(() => (agentId ? Agent.delete(agentId, { cwd }) : undefined))
354
+ .catch(() => { })
355
+ .finally(() => {
356
+ for (const dir of [...cursorProjectDirs(cwd), cwd])
357
+ try {
358
+ rmSync(dir, { recursive: true, force: true });
359
+ }
360
+ catch { }
361
+ });
307
362
  }
308
363
  // scope (opencode session id) is folded in so identical histories from
309
- // different sessions (e.g. parallel sub-agents) never share a Cursor agent
364
+ // different sessions (e.g. parallel sub-agents) never share a Cursor agent.
365
+ // Tool calls hash by id/name/arguments only: opencode tags the last two messages with
366
+ // cache_control for claude-* ids, so the same call serializes differently a turn later.
310
367
  function hashMessages(messages, scope) {
311
- const norm = messages.map((m) => [m.role, textOf(m.content), m.tool_call_id ?? "", JSON.stringify(m.tool_calls ?? null)]);
368
+ const norm = messages.map((m) => [m.role, textOf(m.content), m.tool_call_id ?? "", (m.tool_calls ?? []).map((c) => [c.id, c.function?.name, c.function?.arguments])]);
312
369
  if (scope)
313
370
  norm.unshift(["scope", scope]);
314
371
  return createHash("sha256").update(JSON.stringify(norm)).digest("hex");
@@ -338,13 +395,37 @@ async function loadSessions() {
338
395
  }
339
396
  for (const v of Object.values(raw.entries))
340
397
  if (now - v.lastUsedAt > DISK_SESSION_TTL_MS)
341
- releaseCwd(v.cwd);
398
+ releaseCwd(v.cwd, v.agentId);
342
399
  }
343
400
  catch { }
344
401
  }
402
+ let warnedSave = false;
403
+ // Best effort: a read-only or full cache dir must never fail or hang a request, it only costs resume
404
+ // after a restart. Written via rename so a crash mid-write can't leave torn JSON behind.
345
405
  function saveSessions() {
346
- mkdirSync(SESSION_STORE_DIR, { recursive: true });
347
- writeFileSync(SESSION_STORE_PATH, JSON.stringify({ version: 1, entries: collectDiskEntries() }));
406
+ const tmp = `${SESSION_STORE_PATH}.${randomUUID()}.tmp`;
407
+ try {
408
+ mkdirSync(SESSION_STORE_DIR, { recursive: true });
409
+ const fd = openSync(tmp, "w");
410
+ try {
411
+ writeSync(fd, JSON.stringify({ version: 1, entries: collectDiskEntries() }));
412
+ fsyncSync(fd);
413
+ }
414
+ finally {
415
+ closeSync(fd);
416
+ }
417
+ renameSync(tmp, SESSION_STORE_PATH);
418
+ }
419
+ catch (err) {
420
+ try {
421
+ rmSync(tmp, { force: true });
422
+ }
423
+ catch { }
424
+ if (!warnedSave) {
425
+ warnedSave = true;
426
+ console.error(`[opencode-cursor-auth] could not save ${SESSION_STORE_PATH}, sessions resume only until restart: ${err.message}`);
427
+ }
428
+ }
348
429
  }
349
430
  function sweepSessions(save = true) {
350
431
  const now = Date.now();
@@ -352,12 +433,12 @@ function sweepSessions(save = true) {
352
433
  for (const [k, v] of diskSessions)
353
434
  if (now - v.lastUsedAt > DISK_SESSION_TTL_MS) {
354
435
  diskSessions.delete(k);
355
- evicted.push(v.cwd);
436
+ evicted.push(v);
356
437
  }
357
438
  for (const [k, v] of sessions)
358
439
  if (now - v.lastUsedAt > SESSION_TTL_MS) {
359
440
  sessions.delete(k);
360
- evicted.push(v.cwd);
441
+ evicted.push(v);
361
442
  }
362
443
  while (sessions.size > MAX_SESSIONS) {
363
444
  let oldestKey = "";
@@ -367,11 +448,11 @@ function sweepSessions(save = true) {
367
448
  oldest = v.lastUsedAt;
368
449
  oldestKey = k;
369
450
  }
370
- evicted.push(sessions.get(oldestKey).cwd);
451
+ evicted.push(sessions.get(oldestKey));
371
452
  sessions.delete(oldestKey);
372
453
  }
373
- for (const cwd of evicted)
374
- releaseCwd(cwd);
454
+ for (const v of evicted)
455
+ releaseCwd(v.cwd, v.agentId);
375
456
  if (save)
376
457
  saveSessions();
377
458
  }
@@ -393,13 +474,14 @@ function rememberSession(held, toolCalls) {
393
474
  agentId: held.agent.agentId,
394
475
  cwd: held.cwd,
395
476
  lastUsedAt: Date.now(),
477
+ ...(held.lastPrompt ? { lastPrompt: held.lastPrompt } : {}),
396
478
  };
397
479
  const displaced = sessions.get(key) ?? diskSessions.get(key);
398
480
  sessions.set(key, entry);
399
481
  diskSessions.set(key, entry);
400
482
  sweepSessions();
401
483
  if (displaced && displaced.cwd !== entry.cwd)
402
- releaseCwd(displaced.cwd);
484
+ releaseCwd(displaced.cwd, displaced.agentId);
403
485
  }
404
486
  async function syncAgentsMd(cwd, messages) {
405
487
  mkdirSync(cwd, { recursive: true });
@@ -414,14 +496,19 @@ async function syncAgentsMd(cwd, messages) {
414
496
  else if (existsSync(path))
415
497
  rmSync(path);
416
498
  }
417
- function mapUsage(u) {
499
+ // turn-ended usage sums every model call in the run (n calls, previous turn's prompt L). Prompts only
500
+ // grow within a run, so the last call's prompt is at most S - (n-1)*L: an upper-bound estimate, clamped to [S/n, S].
501
+ export function mapUsage(u, n = 1, L = 0) {
418
502
  if (!u)
419
503
  return undefined;
420
- const prompt = u.inputTokens ?? 0;
504
+ const S = u.inputTokens ?? 0;
505
+ const prompt = n <= 1 ? S : Math.min(S, Math.max(Math.ceil(S / n), S - (n - 1) * L));
421
506
  const completion = u.outputTokens ?? 0;
422
507
  const usage = { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion };
423
- if (u.cacheReadTokens)
424
- usage.prompt_tokens_details = { cached_tokens: u.cacheReadTokens };
508
+ // inputTokens already includes cache; opencode subtracts cached from prompt, so keep it <= prompt
509
+ const cached = Math.min(u.cacheReadTokens ?? 0, prompt);
510
+ if (cached > 0)
511
+ usage.prompt_tokens_details = { cached_tokens: cached };
425
512
  if (u.reasoningTokens)
426
513
  usage.completion_tokens_details = { reasoning_tokens: u.reasoningTokens };
427
514
  return usage;
@@ -447,6 +534,26 @@ function attachSink(held, sink) {
447
534
  for (const b of held.buffer)
448
535
  (b.kind === "text" ? sink.text : sink.reasoning)(b.t);
449
536
  held.buffer = [];
537
+ // a parallel call that missed the previous flush goes out on this segment
538
+ if (held.pendingCalls.length)
539
+ scheduleFlush(held);
540
+ }
541
+ function scheduleFlush(held) {
542
+ if (held.flushTimer)
543
+ clearTimeout(held.flushTimer);
544
+ held.flushTimer = setTimeout(() => flushToolCalls(held), batchSettleMs);
545
+ }
546
+ function armIdle(held) {
547
+ if (held.idleTimer)
548
+ clearTimeout(held.idleTimer);
549
+ held.idleTimer = setTimeout(() => abortHeld(held), TOOL_RESULT_TIMEOUT_MS);
550
+ held.idleTimer.unref?.();
551
+ }
552
+ function releaseHeldSlot(held) {
553
+ if (!held.slot)
554
+ return;
555
+ held.slot = false;
556
+ releaseSlot();
450
557
  }
451
558
  function attachSignal(held, signal) {
452
559
  held.detachSignal?.();
@@ -468,10 +575,7 @@ function closeAgent(held) {
468
575
  }
469
576
  function teardownHeld(held) {
470
577
  held.done = true;
471
- if (held.slot) {
472
- held.slot = false;
473
- releaseSlot();
474
- }
578
+ releaseHeldSlot(held);
475
579
  held.detachSignal?.();
476
580
  held.detachSignal = null;
477
581
  if (held.flushTimer)
@@ -486,7 +590,7 @@ function failHeld(held, err) {
486
590
  if (isAbort(err))
487
591
  return abortHeld(held);
488
592
  teardownHeld(held);
489
- held.run?.cancel().catch(() => { });
593
+ const cancelled = held.run?.cancel().catch(() => { });
490
594
  if (statusFor(err) === 403)
491
595
  markBlocked(held.model);
492
596
  if (held.response && !held.opened) {
@@ -498,22 +602,23 @@ function failHeld(held, err) {
498
602
  held.sink?.fail(err);
499
603
  }
500
604
  closeAgent(held);
501
- releaseCwd(held.cwd);
605
+ releaseCwd(held.cwd, held.agent.agentId, cancelled);
502
606
  }
503
607
  function abortHeld(held) {
504
608
  if (held.done)
505
609
  return;
506
610
  teardownHeld(held);
507
- held.run?.cancel().catch(() => { });
611
+ const cancelled = held.run?.cancel().catch(() => { });
508
612
  closeAgent(held);
509
613
  held.sink?.close();
510
614
  held.reply.reject(new DOMException("aborted", "AbortError"));
511
- releaseCwd(held.cwd);
615
+ releaseCwd(held.cwd, held.agent.agentId, cancelled);
512
616
  }
513
617
  /** Model requested tools: emit tool_calls, end this HTTP segment, park the run. */
514
618
  function flushToolCalls(held) {
515
619
  held.flushTimer = null;
516
- if (held.done || !held.pendingCalls.length)
620
+ // no sink: the calls stay pending until the next continuation attaches one
621
+ if (held.done || !held.sink || !held.pendingCalls.length)
517
622
  return;
518
623
  const calls = held.pendingCalls;
519
624
  held.pendingCalls = [];
@@ -529,8 +634,15 @@ function flushToolCalls(held) {
529
634
  sink?.toolCalls(calls);
530
635
  sink?.finish("tool_calls");
531
636
  rememberSession(held, calls);
532
- held.idleTimer = setTimeout(() => abortHeld(held), TOOL_RESULT_TIMEOUT_MS);
533
- held.idleTimer.unref?.();
637
+ releaseHeldSlot(held);
638
+ armIdle(held);
639
+ }
640
+ /** opencode session went idle (Esc during a tool call): drop its parked runs now, not in 10 min. */
641
+ export function releaseParked(scope) {
642
+ for (const held of [...heldByAgent.values()]) {
643
+ if (held.scope === scope && !held.done && !held.sink && !held.claimed)
644
+ abortHeld(held);
645
+ }
534
646
  }
535
647
  async function wireRunCompletion(held) {
536
648
  if (held.wired || !held.run)
@@ -551,7 +663,9 @@ async function wireRunCompletion(held) {
551
663
  return abortHeld(held);
552
664
  if (result.status === "error")
553
665
  return failHeld(held, result.error ?? new Error("cursor run failed"));
554
- const usage = mapUsage(result.usage) ?? held.usage;
666
+ const usage = mapUsage(result.usage, held.calls + 1, held.lastPrompt) ?? held.usage;
667
+ if (usage)
668
+ held.lastPrompt = usage.prompt_tokens;
555
669
  teardownHeld(held);
556
670
  rememberSession(held, []);
557
671
  openReply(held);
@@ -576,7 +690,7 @@ export function _stats() {
576
690
  disk: diskSessions.size,
577
691
  agentIds: [...sessions.values()].map((v) => v.agentId).sort(),
578
692
  active,
579
- waiting: waiters.length,
693
+ waiting: waiters.length + priorityWaiters.length,
580
694
  };
581
695
  }
582
696
  export async function handleChatCompletion(body, apiKey, signal, scope) {
@@ -591,14 +705,38 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
591
705
  const sink = sse?.sink ?? jsonCollector(body.model, reply.resolve);
592
706
  // continuation: trailing tool results for a parked run
593
707
  const lastMsg = body.messages[body.messages.length - 1];
594
- if (lastMsg?.role === "tool" && lastMsg.tool_call_id) {
595
- const agentId = agentByToolCallId.get(lastMsg.tool_call_id);
596
- held = agentId ? heldByAgent.get(agentId) : undefined;
597
- if (!held || held.done) {
598
- return Response.json({ error: { message: "cursor run for this tool call expired — please retry", type: "invalid_request_error", code: "tool_call_expired" } }, { status: 400 });
599
- }
708
+ let replay = false;
709
+ const parked = lastMsg?.role === "tool" && lastMsg.tool_call_id ? heldByAgent.get(agentByToolCallId.get(lastMsg.tool_call_id) ?? "") : undefined;
710
+ if (parked && !parked.done && (parked.sink || parked.claimed)) {
711
+ return Response.json({ error: { message: "continuation already in progress for this tool call", type: "invalid_request_error", code: "continuation_in_progress" } }, { status: 409 });
712
+ }
713
+ if (parked && !parked.done) {
714
+ held = parked;
715
+ held.claimed = true;
600
716
  if (held.idleTimer)
601
717
  clearTimeout(held.idleTimer);
718
+ try {
719
+ await acquireSlot(signal, true);
720
+ }
721
+ catch (err) {
722
+ held.claimed = false;
723
+ if (!held.done)
724
+ armIdle(held);
725
+ throw err;
726
+ }
727
+ held.claimed = false;
728
+ if (held.done) {
729
+ // torn down while queued (run failed): replay as a fresh turn below
730
+ releaseSlot();
731
+ held = undefined;
732
+ replay = true;
733
+ }
734
+ else
735
+ held.slot = true;
736
+ }
737
+ else if (lastMsg?.role === "tool")
738
+ replay = true;
739
+ if (held) {
602
740
  held.messages = body.messages;
603
741
  held.textAcc = "";
604
742
  held.reply = reply;
@@ -628,10 +766,19 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
628
766
  const prefix = body.messages.slice(0, -1);
629
767
  const key = hashMessages(prefix, scope);
630
768
  await acquireSlot(signal);
631
- const entry = sessions.get(key) ?? diskSessions.get(key);
769
+ // expired tool call: its run is gone, so a fresh agent gets the whole history (tool results included)
770
+ let entry = sessions.get(key) ?? diskSessions.get(key);
771
+ if (entry && replay) {
772
+ sessions.delete(key);
773
+ diskSessions.delete(key);
774
+ saveSessions();
775
+ releaseCwd(entry.cwd, entry.agentId);
776
+ entry = undefined;
777
+ }
632
778
  let agent;
633
779
  let cwd;
634
780
  let newSuffix = body.messages;
781
+ const lastPrompt = entry?.lastPrompt;
635
782
  const agentOptions = (cwd) => ({
636
783
  model,
637
784
  tools: ["mcp"],
@@ -644,7 +791,7 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
644
791
  cwd = entry.cwd;
645
792
  agent = await withRetry(() => Agent.resume(entry.agentId, agentOptions(cwd)), signal).catch(() => {
646
793
  saveSessions();
647
- releaseCwd(entry.cwd);
794
+ releaseCwd(entry.cwd, entry.agentId);
648
795
  return undefined;
649
796
  });
650
797
  if (agent)
@@ -681,9 +828,12 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
681
828
  parked: new Map(),
682
829
  flushTimer: null,
683
830
  idleTimer: null,
831
+ calls: 0,
832
+ lastPrompt,
684
833
  wired: false,
685
834
  done: false,
686
835
  slot: true,
836
+ claimed: false,
687
837
  };
688
838
  const self = held;
689
839
  heldByAgent.set(agent.agentId, held);
@@ -699,23 +849,36 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
699
849
  execute: (args, ctx) => {
700
850
  const id = (ctx.toolCallId ?? `call_${randomUUID()}`).replace(/\s+/g, "_");
701
851
  self.pendingCalls.push({ id, name: t.function.name, argumentsJson: JSON.stringify(args ?? {}) });
702
- if (self.flushTimer)
703
- clearTimeout(self.flushTimer);
704
- self.flushTimer = setTimeout(() => flushToolCalls(self), BATCH_SETTLE_MS);
852
+ if (self.sink)
853
+ scheduleFlush(self);
705
854
  return new Promise((resolve) => self.parked.set(id, resolve));
706
855
  },
707
856
  };
708
857
  }
709
- await syncAgentsMd(cwd, body.messages).catch((err) => {
858
+ try {
859
+ await syncAgentsMd(cwd, body.messages);
860
+ }
861
+ catch (err) {
862
+ // through failHeld so the slot, the held run and the workspace are released
710
863
  if (!self.done)
711
- throw err;
712
- });
864
+ failHeld(self, err);
865
+ return reply.promise;
866
+ }
713
867
  if (self.done)
714
868
  return reply.promise;
869
+ const toolNames = new Map(newSuffix.flatMap((m) => (m.tool_calls ?? []).map((c) => [c.id, c.function?.name])));
715
870
  const prompt = newSuffix
716
871
  .filter((m) => m.role !== "system")
717
- .map((m) => (m.role === "user" ? textOf(m.content) : `${m.role.toUpperCase()}: ${textOf(m.content)}`))
872
+ .map((m) => {
873
+ if (m.role === "user")
874
+ return textOf(m.content);
875
+ if (m.role === "tool")
876
+ return `TOOL(${toolNames.get(m.tool_call_id ?? "") ?? m.tool_call_id}): ${textOf(m.content)}`;
877
+ const calls = (m.tool_calls ?? []).map((c) => `\n[already called ${c.function?.name}(${c.function?.arguments ?? ""})]`).join("");
878
+ return `${m.role.toUpperCase()}: ${textOf(m.content)}${calls}`;
879
+ })
718
880
  .filter(Boolean)
881
+ .concat(newSuffix.at(-1)?.role === "tool" ? ["(The tool calls above already ran and their results are shown. Do not call them again; continue from these results.)"] : [])
719
882
  .join("\n\n") || "(empty message)";
720
883
  let run;
721
884
  try {
@@ -737,8 +900,12 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
737
900
  openReply(self);
738
901
  emit(self, "reasoning", update.text);
739
902
  }
903
+ else if (update.type === "tool-requests-listed") {
904
+ // ponytail: empirical (6/6 live probes), model calls = listed + 1; no per-step usage event exists
905
+ self.calls++;
906
+ }
740
907
  else if (update.type === "turn-ended") {
741
- self.usage = mapUsage(update.usage);
908
+ self.usage = mapUsage(update.usage, self.calls + 1, self.lastPrompt);
742
909
  }
743
910
  },
744
911
  local: { customTools: customTools },
package/dist/models.js CHANGED
@@ -5,6 +5,8 @@ import { join } from "node:path";
5
5
  const CACHE_DIR = join(homedir(), ".cache", "opencode-cursor-auth");
6
6
  const MODELS_CACHE_PATH = join(CACHE_DIR, "models.json");
7
7
  const MODELS_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
8
+ const MODELS_FETCH_TIMEOUT_MS = 15_000;
9
+ const MODELS_RETRY_MS = 5 * 60 * 1000;
8
10
  let catalog = {};
9
11
  let fetchedAt = 0;
10
12
  function saveCatalog() {
@@ -68,31 +70,56 @@ function loadCachedModels() {
68
70
  }
69
71
  return undefined;
70
72
  }
71
- // Live model list from Cursor, cached 24h to keep opencode startup fast.
72
- // Offline/logged-out: stale cache if present, else just "auto" (Cursor's universal alias).
73
+ let refreshing;
74
+ let lastAttempt = 0;
75
+ function refreshCatalog() {
76
+ lastAttempt = Date.now();
77
+ refreshing ??= (async () => {
78
+ let timer;
79
+ const timeout = new Promise((_, reject) => {
80
+ timer = setTimeout(() => reject(new Error("timed out")), MODELS_FETCH_TIMEOUT_MS);
81
+ // never keep `opencode run` alive just for a catalog refresh
82
+ timer.unref?.();
83
+ });
84
+ try {
85
+ const fresh = buildCatalog(await Promise.race([Cursor.models.list(), timeout]));
86
+ for (const [id, m] of Object.entries(catalog))
87
+ if (m.blocked && fresh[id])
88
+ fresh[id].blocked = true;
89
+ catalog = fresh;
90
+ fetchedAt = Date.now();
91
+ try {
92
+ saveCatalog();
93
+ }
94
+ catch {
95
+ // cache dir unwritable — the fresh list still serves this process
96
+ }
97
+ }
98
+ finally {
99
+ clearTimeout(timer);
100
+ }
101
+ })()
102
+ .catch(() => { })
103
+ .finally(() => (refreshing = undefined));
104
+ }
105
+ // Live model list from Cursor, cached 24h. A stale cache is served as is and refreshed in the
106
+ // background (new models show up on the next start), so opencode's startup never waits on Cursor;
107
+ // only a first run with no cache does, for at most MODELS_FETCH_TIMEOUT_MS.
108
+ // Offline/logged-out with no cache: just "auto" (Cursor's universal alias).
73
109
  // Models Cursor has rejected with "Model Blocked" for this account are hidden
74
110
  // until the cache file is deleted (the catalog itself carries no entitlement info).
75
111
  export async function currentModels() {
76
112
  const cached = loadCachedModels();
77
- if (cached && Date.now() - cached.fetchedAt <= MODELS_CACHE_TTL_MS) {
113
+ if (cached && cached.fetchedAt >= fetchedAt) {
78
114
  catalog = cached.models;
79
115
  fetchedAt = cached.fetchedAt;
80
116
  }
81
- else {
82
- try {
83
- catalog = buildCatalog(await Cursor.models.list());
84
- for (const [id, m] of Object.entries(cached?.models ?? {}))
85
- if (m.blocked && catalog[id])
86
- catalog[id].blocked = true;
87
- fetchedAt = Date.now();
88
- saveCatalog();
89
- }
90
- catch {
91
- catalog = cached?.models ?? { auto: { name: "Auto" } };
92
- fetchedAt = cached?.fetchedAt ?? 0;
93
- }
94
- }
95
- return Object.fromEntries(Object.entries(catalog).filter(([, m]) => !m.blocked));
117
+ if (Date.now() - fetchedAt > MODELS_CACHE_TTL_MS && Date.now() - lastAttempt > MODELS_RETRY_MS)
118
+ refreshCatalog();
119
+ if (!Object.keys(catalog).length)
120
+ await refreshing;
121
+ const models = Object.keys(catalog).length ? catalog : { auto: { name: "Auto" } };
122
+ return Object.fromEntries(Object.entries(models).filter(([, m]) => !m.blocked));
96
123
  }
97
124
  function loadCatalog() {
98
125
  if (Object.keys(catalog).length)
package/dist/plugin.js CHANGED
@@ -1,11 +1,15 @@
1
1
  import { Cursor } from "@cursor/sdk";
2
- import { errorResponse, handleChatCompletion } from "./bridge.js";
2
+ import { errorResponse, handleChatCompletion, releaseParked } from "./bridge.js";
3
3
  import { currentModels } from "./models.js";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  export { parseContext } from "./models.js";
8
8
  const BASE_URL = "http://cursor.local/v1";
9
+ // opencode aborts a request after 5 min without response headers, or between chunks. A Cursor turn can
10
+ // sit longer: queued for a slot, reasoning silently, or writing a large tool argument (sent only once
11
+ // complete). Set provider.cursor.options to override.
12
+ const STREAM_TIMEOUT_MS = 30 * 60 * 1000;
9
13
  // Cursor publishes no output limit and the bridge never passes max_tokens to Cursor, so output is only an accounting reserve for opencode.
10
14
  const DEFAULT_CONTEXT = 200_000;
11
15
  const DEFAULT_OUTPUT = 32_000;
@@ -94,7 +98,7 @@ export const CursorAuthPlugin = async (input) => {
94
98
  const cursor = (providers["cursor"] ??= { models: {} });
95
99
  cursor.npm ??= "@ai-sdk/openai-compatible";
96
100
  cursor.name ??= "Cursor";
97
- cursor.options = { ...cursor.options, baseURL: BASE_URL };
101
+ cursor.options = { headerTimeout: STREAM_TIMEOUT_MS, chunkTimeout: STREAM_TIMEOUT_MS, ...cursor.options, baseURL: BASE_URL };
98
102
  cursor.models ??= {};
99
103
  for (const [id, { name, context, variants }] of Object.entries(await currentModels())) {
100
104
  cursor.models[id] ??= { name };
@@ -105,6 +109,11 @@ export const CursorAuthPlugin = async (input) => {
105
109
  model.variants ??= Object.fromEntries(Object.entries(variants).map(([n, v]) => [n, { cursor_params: v.params }]));
106
110
  }
107
111
  },
112
+ // Esc during a tool call: the session goes idle and nothing will continue its parked Cursor run
113
+ event: async ({ event }) => {
114
+ if (event.type === "session.idle" || (event.type === "session.status" && event.properties.status.type === "idle"))
115
+ releaseParked(event.properties.sessionID);
116
+ },
108
117
  "chat.headers": async (input, output) => {
109
118
  if (input.model.providerID === "cursor")
110
119
  output.headers["x-opencode-session"] = input.sessionID;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vidit19sharma/opencode-cursor-auth",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "OpenCode plugin that uses your Cursor subscription via the official @cursor/sdk — no proxy server, no separate login.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",