@vidit19sharma/opencode-cursor-auth 0.3.2 → 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,34 @@
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
+
19
+ ## 0.3.3 — 2026-10-16
20
+
21
+ ### Added
22
+
23
+ - `npx @vidit19sharma/opencode-cursor-auth update` — removes opencode's cached copy of the plugin so the next opencode start installs the current release. opencode resolves `@latest` once at first install and never re-checks, so users were silently stuck on the version they first installed (`--dry-run` supported).
24
+ - Mocked plugin unit tests (`bun run test:plugin`) covering key handling, expiry, headless login URL passthrough and auth-entry seeding.
25
+
26
+ ### Fixed
27
+
28
+ - Pressing Esc before the first byte (during agent setup) returned HTTP 502 "aborted"; it now propagates as a proper abort.
29
+ - `@cursor/sdk` INFO/WARN log lines leaked into the opencode TUI: the lines are ANSI-coloured when stdout is a TTY, so the filter never matched there. Colours are stripped before matching and the filter is re-asserted per request.
30
+ - An empty or whitespace API key stored via the paste method silently fell back to the SDK key store; it now returns 401 `invalid_api_key`.
31
+ - A failure after content has already streamed emits a single SSE `error` frame and closes without `[DONE]` (covered by a test).
32
+
5
33
  ## 0.3.2 — 2026-10-16
6
34
 
7
35
  ### 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
@@ -85,6 +85,18 @@ opencode run -m cursor/auto "hello"
85
85
 
86
86
  Or pick any `cursor/*` model from the opencode model selector.
87
87
 
88
+ ## Upgrading
89
+
90
+ ```sh
91
+ npx @vidit19sharma/opencode-cursor-auth update
92
+ ```
93
+
94
+ Then restart opencode. opencode pins `@latest` to the version it first installed and never re-checks npm, so this deletes that cached copy and opencode installs the current release on next start. Tell-tale sign you need it: Ctrl+T shows no variants, or features from the changelog are missing. Manual equivalent:
95
+
96
+ ```sh
97
+ rm -rf ~/.cache/opencode/packages/@vidit19sharma/opencode-cursor-auth@latest
98
+ ```
99
+
88
100
  ## Models
89
101
 
90
102
  ```sh
@@ -105,7 +117,7 @@ opencode models | grep cursor/
105
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.
106
118
  - **Offline / logged out**: the stale cache is used if present, otherwise only `cursor/auto`.
107
119
 
108
- 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).
109
121
 
110
122
  ### Migrating from 0.2.0
111
123
 
@@ -119,12 +131,14 @@ The models cache is refetched once on upgrade.
119
131
  | --- | --- | --- |
120
132
  | `OPENCODE_CURSOR_MAX_ACTIVE` | `4` | Max Cursor runs active at once; extra requests queue FIFO |
121
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) |
122
135
 
123
136
  | Path | Contents |
124
137
  | --- | --- |
125
138
  | `~/.cursor/sdk/auth.json` | 90-day SDK key from browser login (written by `@cursor/sdk`) |
126
139
  | `~/.cache/opencode-cursor-auth/models.json` | Model catalog, 24h TTL, blocked-model flags |
127
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 |
128
142
 
129
143
  ## How it works
130
144
 
@@ -136,7 +150,7 @@ opencode ──(OpenAI chat JSON)──> plugin fetch() ──> @cursor/sdk Agen
136
150
  - opencode calls the provider's `fetch` — the plugin intercepts it in-process; `http://cursor.local/v1` is a dummy URL that never resolves.
137
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.
138
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`.
139
- - 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.
140
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`.
141
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.
142
156
  - The HTTP reply opens on the first text or thinking delta, so failures before any content become real HTTP errors.
@@ -148,8 +162,9 @@ opencode ──(OpenAI chat JSON)──> plugin fetch() ──> @cursor/sdk Agen
148
162
  | --- | --- |
149
163
  | 400 | Unknown model or bad argument (Cursor's message), unknown variant param; also an expired tool-call continuation |
150
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 |
151
166
  | 403 | Model blocked for your account (`permission_error`) |
152
- | 429 | Cursor rate limit |
167
+ | 429 | Cursor rate limit; carries `retry-after` when Cursor's message names a wait |
153
168
  | 502 | Anything else |
154
169
 
155
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]`.
@@ -165,7 +180,7 @@ bun run test:cli # exercises the npx setup command against temp config dirs
165
180
  bun run smoke # packs the plugin and loads it in a pristine HOME (startup, auth login, first request before login)
166
181
  ```
167
182
 
168
- 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.
169
184
 
170
185
  ## Known limitations
171
186
 
package/dist/bridge.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export declare function ensureQuietConsole(): void;
1
2
  type ContentPart = {
2
3
  type: string;
3
4
  text?: string;
@@ -29,11 +30,32 @@ type OaBody = {
29
30
  stream?: boolean;
30
31
  cursor_params?: unknown;
31
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
+ };
32
44
  export declare function isTransient(err: unknown): boolean;
33
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;
34
47
  export declare function _setMaxActive(n: number): void;
35
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;
36
57
  export declare function _resetForTest(): void;
58
+ export declare function _failAfterFirstDelta(message: string | null): void;
37
59
  export declare function _stats(): {
38
60
  held: number;
39
61
  sessions: number;
package/dist/bridge.js CHANGED
@@ -10,21 +10,33 @@
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
- // @cursor/sdk's internal logger writes "HH:MM:SS.mmm INFO ..." lines via
19
- // console.log unconditionally (no config/env to silence it, verified against
20
- // the esm bundle). Filter exactly those lines, pass everything else through.
21
- const SDK_LOG_LINE = /^\d{2}:\d{2}:\d{2}\.\d{3} (TRACE|DEBUG|INFO|WARN|ERROR)\s/;
22
- const originalConsoleLog = console.log.bind(console);
23
- console.log = (...args) => {
24
- if (typeof args[0] === "string" && SDK_LOG_LINE.test(args[0]))
25
- return;
26
- originalConsoleLog(...args);
27
- };
18
+ // @cursor/sdk has no log-level switch: it prints "HH:MM:SS.mmm INFO ..." through
19
+ // whatever console.* is installed at call time, ANSI-coloured when stdout is a
20
+ // TTY (the opencode TUI). Match after stripping colours; re-wrap per request so
21
+ // a host that replaces console.* after plugin load cannot bypass the filter.
22
+ const SDK_LOG_LINE = /^\d{2}:\d{2}:\d{2}\.\d{3} (TRACE|DEBUG|INFO|WARN)\s/;
23
+ const ANSI = /\x1b\[[0-9;]*m/g;
24
+ const QUIET = Symbol.for("opencode-cursor-auth.quiet");
25
+ export function ensureQuietConsole() {
26
+ for (const m of ["log", "info", "warn", "debug"]) {
27
+ const current = console[m];
28
+ if (current[QUIET])
29
+ continue;
30
+ const wrapped = (...args) => {
31
+ if (typeof args[0] === "string" && SDK_LOG_LINE.test(args[0].replace(ANSI, "")))
32
+ return;
33
+ current(...args);
34
+ };
35
+ wrapped[QUIET] = true;
36
+ console[m] = wrapped;
37
+ }
38
+ }
39
+ ensureQuietConsole();
28
40
  function textOf(content) {
29
41
  if (typeof content === "string")
30
42
  return content;
@@ -32,15 +44,27 @@ function textOf(content) {
32
44
  return "";
33
45
  return content.map((p) => p.text ?? "").join("");
34
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"]);
35
52
  function statusFor(err) {
36
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;
37
58
  const msg = String(e.message ?? err ?? "");
38
59
  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))
60
+ if (err instanceof AuthenticationError || e.status === 401 || KEY_CODES.has(code) || /invalid user api key/i.test(msg))
40
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;
41
65
  if (err instanceof RateLimitError || e.status === 429 || code === "resource_exhausted" || /rate limit/i.test(msg))
42
66
  return 429;
43
- 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))
44
68
  return 403;
45
69
  if (err instanceof ConfigurationError || code === "invalid_argument" || code === "not_found" || /cannot use this model|unknown model/i.test(msg))
46
70
  return 400;
@@ -49,9 +73,19 @@ function statusFor(err) {
49
73
  const ERROR_TYPE = {
50
74
  400: "invalid_request_error",
51
75
  401: "authentication_error",
76
+ 402: "insufficient_quota",
52
77
  403: "permission_error",
53
78
  429: "rate_limit_error",
54
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
+ }
55
89
  function errorBody(err, status = statusFor(err)) {
56
90
  const e = (typeof err === "object" && err !== null ? err : {});
57
91
  return {
@@ -63,16 +97,19 @@ function errorBody(err, status = statusFor(err)) {
63
97
  };
64
98
  }
65
99
  const abortError = () => new DOMException("aborted", "AbortError");
100
+ const isAbort = (err) => err?.name === "AbortError";
66
101
  export function isTransient(err) {
67
102
  const e = (typeof err === "object" && err !== null ? err : {});
68
- 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")
69
105
  return false;
70
106
  const status = statusFor(err);
71
107
  if (status === 429)
72
108
  return true;
73
- if (status === 400 || status === 401 || status === 403 || e.status === 404)
109
+ if (status === 400 || status === 401 || status === 402 || status === 403 || e.status === 404)
74
110
  return false;
75
- 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))
76
113
  return true;
77
114
  return /unavailable|deadline_exceeded|ECONNRESET|ETIMEDOUT|fetch failed/i.test(`${e.code ?? ""} ${e.message ?? ""}`);
78
115
  }
@@ -84,7 +121,9 @@ export async function withRetry(fn, signal, baseDelayMs = 1000, retryable = isTr
84
121
  return await fn();
85
122
  }
86
123
  catch (err) {
87
- if (attempt >= 2 || signal?.aborted || !retryable(err))
124
+ if (signal?.aborted || isAbort(err))
125
+ throw abortError();
126
+ if (attempt >= 2 || !retryable(err))
88
127
  throw err;
89
128
  const delay = Math.min(baseDelayMs * 2 ** attempt * (0.75 + Math.random() * 0.5), 30_000);
90
129
  await new Promise((r) => {
@@ -94,28 +133,31 @@ export async function withRetry(fn, signal, baseDelayMs = 1000, retryable = isTr
94
133
  }
95
134
  }
96
135
  }
97
- // 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
98
138
  let maxActive = Number(process.env.OPENCODE_CURSOR_MAX_ACTIVE) || 4;
99
139
  let active = 0;
100
140
  const waiters = [];
141
+ const priorityWaiters = [];
101
142
  function pump() {
102
- while (active < maxActive && waiters.length) {
143
+ while (active < maxActive && (priorityWaiters.length || waiters.length)) {
103
144
  active++;
104
- waiters.shift()();
145
+ (priorityWaiters.shift() ?? waiters.shift())();
105
146
  }
106
147
  }
107
- function acquireSlot(signal) {
148
+ function acquireSlot(signal, priority = false) {
108
149
  if (signal?.aborted)
109
150
  return Promise.reject(abortError());
110
- if (active < maxActive && !waiters.length) {
151
+ const queue = priority ? priorityWaiters : waiters;
152
+ if (active < maxActive && !priorityWaiters.length && (priority || !waiters.length)) {
111
153
  active++;
112
154
  return Promise.resolve();
113
155
  }
114
156
  return new Promise((resolve, reject) => {
115
157
  const onAbort = () => {
116
- const i = waiters.indexOf(go);
158
+ const i = queue.indexOf(go);
117
159
  if (i >= 0)
118
- waiters.splice(i, 1);
160
+ queue.splice(i, 1);
119
161
  reject(abortError());
120
162
  };
121
163
  const go = () => {
@@ -123,19 +165,24 @@ function acquireSlot(signal) {
123
165
  resolve();
124
166
  };
125
167
  signal?.addEventListener("abort", onAbort, { once: true });
126
- waiters.push(go);
168
+ queue.push(go);
127
169
  });
128
170
  }
129
171
  function releaseSlot() {
130
172
  active--;
131
173
  pump();
132
174
  }
175
+ export function _setBatchSettleMs(ms) {
176
+ batchSettleMs = ms;
177
+ }
133
178
  export function _setMaxActive(n) {
134
179
  maxActive = n;
135
180
  pump();
136
181
  }
137
182
  export function errorResponse(err, status = statusFor(err)) {
138
- 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) } } : {}) });
139
186
  }
140
187
  function chunk(id, model, delta, finish, usage) {
141
188
  return {
@@ -261,7 +308,7 @@ function deferred() {
261
308
  });
262
309
  return { promise, resolve, reject };
263
310
  }
264
- const BATCH_SETTLE_MS = 300;
311
+ let batchSettleMs = 300;
265
312
  const TOOL_RESULT_TIMEOUT_MS = 10 * 60 * 1000;
266
313
  const SESSION_TTL_MS = 30 * 60 * 1000;
267
314
  const DISK_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
@@ -275,9 +322,23 @@ const heldByAgent = new Map();
275
322
  const agentByToolCallId = new Map();
276
323
  let diskSessions = new Map();
277
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));
278
336
  // ponytail: no orphan sweep, other opencode instances may own those dirs
279
- function releaseCwd(cwd) {
280
- 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))
281
342
  return;
282
343
  for (const v of sessions.values())
283
344
  if (v.cwd === cwd)
@@ -288,12 +349,23 @@ function releaseCwd(cwd) {
288
349
  for (const h of heldByAgent.values())
289
350
  if (h.cwd === cwd)
290
351
  return;
291
- 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
+ });
292
362
  }
293
363
  // scope (opencode session id) is folded in so identical histories from
294
- // 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.
295
367
  function hashMessages(messages, scope) {
296
- 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])]);
297
369
  if (scope)
298
370
  norm.unshift(["scope", scope]);
299
371
  return createHash("sha256").update(JSON.stringify(norm)).digest("hex");
@@ -323,13 +395,37 @@ async function loadSessions() {
323
395
  }
324
396
  for (const v of Object.values(raw.entries))
325
397
  if (now - v.lastUsedAt > DISK_SESSION_TTL_MS)
326
- releaseCwd(v.cwd);
398
+ releaseCwd(v.cwd, v.agentId);
327
399
  }
328
400
  catch { }
329
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.
330
405
  function saveSessions() {
331
- mkdirSync(SESSION_STORE_DIR, { recursive: true });
332
- 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
+ }
333
429
  }
334
430
  function sweepSessions(save = true) {
335
431
  const now = Date.now();
@@ -337,12 +433,12 @@ function sweepSessions(save = true) {
337
433
  for (const [k, v] of diskSessions)
338
434
  if (now - v.lastUsedAt > DISK_SESSION_TTL_MS) {
339
435
  diskSessions.delete(k);
340
- evicted.push(v.cwd);
436
+ evicted.push(v);
341
437
  }
342
438
  for (const [k, v] of sessions)
343
439
  if (now - v.lastUsedAt > SESSION_TTL_MS) {
344
440
  sessions.delete(k);
345
- evicted.push(v.cwd);
441
+ evicted.push(v);
346
442
  }
347
443
  while (sessions.size > MAX_SESSIONS) {
348
444
  let oldestKey = "";
@@ -352,11 +448,11 @@ function sweepSessions(save = true) {
352
448
  oldest = v.lastUsedAt;
353
449
  oldestKey = k;
354
450
  }
355
- evicted.push(sessions.get(oldestKey).cwd);
451
+ evicted.push(sessions.get(oldestKey));
356
452
  sessions.delete(oldestKey);
357
453
  }
358
- for (const cwd of evicted)
359
- releaseCwd(cwd);
454
+ for (const v of evicted)
455
+ releaseCwd(v.cwd, v.agentId);
360
456
  if (save)
361
457
  saveSessions();
362
458
  }
@@ -378,13 +474,14 @@ function rememberSession(held, toolCalls) {
378
474
  agentId: held.agent.agentId,
379
475
  cwd: held.cwd,
380
476
  lastUsedAt: Date.now(),
477
+ ...(held.lastPrompt ? { lastPrompt: held.lastPrompt } : {}),
381
478
  };
382
479
  const displaced = sessions.get(key) ?? diskSessions.get(key);
383
480
  sessions.set(key, entry);
384
481
  diskSessions.set(key, entry);
385
482
  sweepSessions();
386
483
  if (displaced && displaced.cwd !== entry.cwd)
387
- releaseCwd(displaced.cwd);
484
+ releaseCwd(displaced.cwd, displaced.agentId);
388
485
  }
389
486
  async function syncAgentsMd(cwd, messages) {
390
487
  mkdirSync(cwd, { recursive: true });
@@ -399,14 +496,19 @@ async function syncAgentsMd(cwd, messages) {
399
496
  else if (existsSync(path))
400
497
  rmSync(path);
401
498
  }
402
- 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) {
403
502
  if (!u)
404
503
  return undefined;
405
- 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));
406
506
  const completion = u.outputTokens ?? 0;
407
507
  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 };
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 };
410
512
  if (u.reasoningTokens)
411
513
  usage.completion_tokens_details = { reasoning_tokens: u.reasoningTokens };
412
514
  return usage;
@@ -432,12 +534,35 @@ function attachSink(held, sink) {
432
534
  for (const b of held.buffer)
433
535
  (b.kind === "text" ? sink.text : sink.reasoning)(b.t);
434
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();
435
557
  }
436
558
  function attachSignal(held, signal) {
437
559
  held.detachSignal?.();
438
560
  held.detachSignal = null;
439
561
  if (!signal)
440
562
  return;
563
+ // listeners never fire on an already-aborted signal (abort during Agent.create/resume)
564
+ if (signal.aborted)
565
+ return abortHeld(held);
441
566
  const onAbort = () => abortHeld(held);
442
567
  signal.addEventListener("abort", onAbort, { once: true });
443
568
  held.detachSignal = () => signal.removeEventListener("abort", onAbort);
@@ -450,10 +575,7 @@ function closeAgent(held) {
450
575
  }
451
576
  function teardownHeld(held) {
452
577
  held.done = true;
453
- if (held.slot) {
454
- held.slot = false;
455
- releaseSlot();
456
- }
578
+ releaseHeldSlot(held);
457
579
  held.detachSignal?.();
458
580
  held.detachSignal = null;
459
581
  if (held.flushTimer)
@@ -465,7 +587,10 @@ function teardownHeld(held) {
465
587
  heldByAgent.delete(held.agent.agentId);
466
588
  }
467
589
  function failHeld(held, err) {
590
+ if (isAbort(err))
591
+ return abortHeld(held);
468
592
  teardownHeld(held);
593
+ const cancelled = held.run?.cancel().catch(() => { });
469
594
  if (statusFor(err) === 403)
470
595
  markBlocked(held.model);
471
596
  if (held.response && !held.opened) {
@@ -477,22 +602,23 @@ function failHeld(held, err) {
477
602
  held.sink?.fail(err);
478
603
  }
479
604
  closeAgent(held);
480
- releaseCwd(held.cwd);
605
+ releaseCwd(held.cwd, held.agent.agentId, cancelled);
481
606
  }
482
607
  function abortHeld(held) {
483
608
  if (held.done)
484
609
  return;
485
610
  teardownHeld(held);
486
- held.run?.cancel().catch(() => { });
611
+ const cancelled = held.run?.cancel().catch(() => { });
487
612
  closeAgent(held);
488
613
  held.sink?.close();
489
614
  held.reply.reject(new DOMException("aborted", "AbortError"));
490
- releaseCwd(held.cwd);
615
+ releaseCwd(held.cwd, held.agent.agentId, cancelled);
491
616
  }
492
617
  /** Model requested tools: emit tool_calls, end this HTTP segment, park the run. */
493
618
  function flushToolCalls(held) {
494
619
  held.flushTimer = null;
495
- 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)
496
622
  return;
497
623
  const calls = held.pendingCalls;
498
624
  held.pendingCalls = [];
@@ -508,8 +634,15 @@ function flushToolCalls(held) {
508
634
  sink?.toolCalls(calls);
509
635
  sink?.finish("tool_calls");
510
636
  rememberSession(held, calls);
511
- held.idleTimer = setTimeout(() => abortHeld(held), TOOL_RESULT_TIMEOUT_MS);
512
- 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
+ }
513
646
  }
514
647
  async function wireRunCompletion(held) {
515
648
  if (held.wired || !held.run)
@@ -530,7 +663,9 @@ async function wireRunCompletion(held) {
530
663
  return abortHeld(held);
531
664
  if (result.status === "error")
532
665
  return failHeld(held, result.error ?? new Error("cursor run failed"));
533
- 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;
534
669
  teardownHeld(held);
535
670
  rememberSession(held, []);
536
671
  openReply(held);
@@ -544,6 +679,10 @@ export function _resetForTest() {
544
679
  agentByToolCallId.clear();
545
680
  sessionsLoaded = false;
546
681
  }
682
+ let failAfterFirstDelta = null;
683
+ export function _failAfterFirstDelta(message) {
684
+ failAfterFirstDelta = message;
685
+ }
547
686
  export function _stats() {
548
687
  return {
549
688
  held: heldByAgent.size,
@@ -551,12 +690,13 @@ export function _stats() {
551
690
  disk: diskSessions.size,
552
691
  agentIds: [...sessions.values()].map((v) => v.agentId).sort(),
553
692
  active,
554
- waiting: waiters.length,
693
+ waiting: waiters.length + priorityWaiters.length,
555
694
  };
556
695
  }
557
696
  export async function handleChatCompletion(body, apiKey, signal, scope) {
558
697
  if (signal?.aborted)
559
698
  throw new DOMException("aborted", "AbortError");
699
+ ensureQuietConsole();
560
700
  await loadSessions();
561
701
  const streaming = body.stream !== false;
562
702
  const reply = deferred();
@@ -565,20 +705,46 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
565
705
  const sink = sse?.sink ?? jsonCollector(body.model, reply.resolve);
566
706
  // continuation: trailing tool results for a parked run
567
707
  const lastMsg = body.messages[body.messages.length - 1];
568
- if (lastMsg?.role === "tool" && lastMsg.tool_call_id) {
569
- const agentId = agentByToolCallId.get(lastMsg.tool_call_id);
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
- }
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;
574
716
  if (held.idleTimer)
575
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) {
576
740
  held.messages = body.messages;
577
741
  held.textAcc = "";
578
742
  held.reply = reply;
579
743
  held.response = sse?.response ?? null;
580
744
  held.opened = false;
581
745
  attachSignal(held, signal);
746
+ if (held.done)
747
+ return reply.promise;
582
748
  attachSink(held, sink);
583
749
  for (const m of body.messages) {
584
750
  if (m.role !== "tool" || !m.tool_call_id)
@@ -600,10 +766,19 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
600
766
  const prefix = body.messages.slice(0, -1);
601
767
  const key = hashMessages(prefix, scope);
602
768
  await acquireSlot(signal);
603
- 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
+ }
604
778
  let agent;
605
779
  let cwd;
606
780
  let newSuffix = body.messages;
781
+ const lastPrompt = entry?.lastPrompt;
607
782
  const agentOptions = (cwd) => ({
608
783
  model,
609
784
  tools: ["mcp"],
@@ -616,7 +791,7 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
616
791
  cwd = entry.cwd;
617
792
  agent = await withRetry(() => Agent.resume(entry.agentId, agentOptions(cwd)), signal).catch(() => {
618
793
  saveSessions();
619
- releaseCwd(entry.cwd);
794
+ releaseCwd(entry.cwd, entry.agentId);
620
795
  return undefined;
621
796
  });
622
797
  if (agent)
@@ -630,7 +805,7 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
630
805
  catch (err) {
631
806
  releaseSlot();
632
807
  releaseCwd(cwd);
633
- if (signal?.aborted)
808
+ if (signal?.aborted || isAbort(err))
634
809
  throw abortError();
635
810
  return errorResponse(err);
636
811
  }
@@ -653,13 +828,18 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
653
828
  parked: new Map(),
654
829
  flushTimer: null,
655
830
  idleTimer: null,
831
+ calls: 0,
832
+ lastPrompt,
656
833
  wired: false,
657
834
  done: false,
658
835
  slot: true,
836
+ claimed: false,
659
837
  };
660
838
  const self = held;
661
839
  heldByAgent.set(agent.agentId, held);
662
840
  attachSignal(held, signal);
841
+ if (held.done)
842
+ return reply.promise;
663
843
  attachSink(held, sink);
664
844
  const customTools = {};
665
845
  for (const t of body.tools ?? []) {
@@ -669,18 +849,36 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
669
849
  execute: (args, ctx) => {
670
850
  const id = (ctx.toolCallId ?? `call_${randomUUID()}`).replace(/\s+/g, "_");
671
851
  self.pendingCalls.push({ id, name: t.function.name, argumentsJson: JSON.stringify(args ?? {}) });
672
- if (self.flushTimer)
673
- clearTimeout(self.flushTimer);
674
- self.flushTimer = setTimeout(() => flushToolCalls(self), BATCH_SETTLE_MS);
852
+ if (self.sink)
853
+ scheduleFlush(self);
675
854
  return new Promise((resolve) => self.parked.set(id, resolve));
676
855
  },
677
856
  };
678
857
  }
679
- await syncAgentsMd(cwd, body.messages);
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
863
+ if (!self.done)
864
+ failHeld(self, err);
865
+ return reply.promise;
866
+ }
867
+ if (self.done)
868
+ return reply.promise;
869
+ const toolNames = new Map(newSuffix.flatMap((m) => (m.tool_calls ?? []).map((c) => [c.id, c.function?.name])));
680
870
  const prompt = newSuffix
681
871
  .filter((m) => m.role !== "system")
682
- .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
+ })
683
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.)"] : [])
684
882
  .join("\n\n") || "(empty message)";
685
883
  let run;
686
884
  try {
@@ -692,13 +890,22 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
692
890
  openReply(self);
693
891
  self.textAcc += update.text;
694
892
  emit(self, "text", update.text);
893
+ if (failAfterFirstDelta !== null) {
894
+ const message = failAfterFirstDelta;
895
+ failAfterFirstDelta = null;
896
+ failHeld(self, new Error(message));
897
+ }
695
898
  }
696
899
  else if (update.type === "thinking-delta" && update.text) {
697
900
  openReply(self);
698
901
  emit(self, "reasoning", update.text);
699
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
+ }
700
907
  else if (update.type === "turn-ended") {
701
- self.usage = mapUsage(update.usage);
908
+ self.usage = mapUsage(update.usage, self.calls + 1, self.lastPrompt);
702
909
  }
703
910
  },
704
911
  local: { customTools: customTools },
package/dist/cli.js CHANGED
@@ -1,16 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import process from "node:process";
6
6
  const PKG = "@vidit19sharma/opencode-cursor-auth";
7
7
  const ENTRY = `${PKG}@latest`;
8
8
  const HELP = `Usage: npx ${PKG} [setup] [--dry-run] [--config <path>]
9
+ npx ${PKG} update [--dry-run]
9
10
 
10
- Adds "${ENTRY}" to the "plugin" array of your opencode config.
11
+ setup (default): adds "${ENTRY}" to the "plugin" array of your opencode config.
11
12
  Idempotent: does nothing if any ${PKG} entry is already present.
12
13
 
13
- --dry-run print what would change, write nothing
14
+ update: deletes opencode's cached copy of ${PKG} (opencode never re-resolves
15
+ "@latest" once installed), so the next opencode start installs the latest version.
16
+ Cache: $XDG_CACHE_HOME/opencode or ~/.cache/opencode.
17
+
18
+ --dry-run print what would change, write/delete nothing
14
19
  --config <path> edit this file instead of the auto-detected one
15
20
  --help show this help
16
21
 
@@ -36,9 +41,45 @@ function tryParse(s) {
36
41
  return undefined;
37
42
  }
38
43
  }
44
+ // opencode caches npm plugins in <cache>/packages/<name>@<spec>/ (older: <cache>/npm/) and never re-resolves @latest.
45
+ function update(dryRun) {
46
+ const cache = join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "opencode");
47
+ const [scope, name] = PKG.split("/");
48
+ const dirs = ["packages", "npm"].flatMap((p) => {
49
+ const parent = join(cache, p, scope);
50
+ if (!existsSync(parent))
51
+ return [];
52
+ return readdirSync(parent)
53
+ .filter((d) => d.startsWith(`${name}@`))
54
+ .map((d) => join(parent, d));
55
+ });
56
+ if (!dirs.length) {
57
+ console.log(`nothing to update: opencode has no cached copy of ${PKG} (it will install the latest version on next start)`);
58
+ return 0;
59
+ }
60
+ for (const d of dirs) {
61
+ const pkg = tryParse(readFileSafe(join(d, "node_modules", PKG, "package.json")));
62
+ const version = typeof pkg?.version === "string" ? pkg.version : "unknown version";
63
+ if (!dryRun)
64
+ rmSync(d, { recursive: true, force: true });
65
+ console.log(`${dryRun ? "would remove" : "removed"} cached ${version} at ${d}`);
66
+ }
67
+ if (!dryRun)
68
+ console.log(`restart opencode — it will install the latest ${PKG} from npm on start`);
69
+ return 0;
70
+ }
71
+ function readFileSafe(f) {
72
+ try {
73
+ return readFileSync(f, "utf8");
74
+ }
75
+ catch {
76
+ return "";
77
+ }
78
+ }
39
79
  function main(argv) {
40
80
  let dryRun = false;
41
81
  let explicit;
82
+ let cmd = "setup";
42
83
  for (let i = 0; i < argv.length; i++) {
43
84
  const a = argv[i];
44
85
  if (a === "--help" || a === "-h")
@@ -47,11 +88,15 @@ function main(argv) {
47
88
  dryRun = true;
48
89
  else if (a === "--config")
49
90
  explicit = argv[++i];
50
- else if (a !== "setup")
91
+ else if (a === "setup" || a === "update")
92
+ cmd = a;
93
+ else
51
94
  return console.error(`Unknown argument: ${a}\n\n${HELP}`), 1;
52
95
  }
53
96
  if (argv.includes("--config") && !explicit)
54
97
  return console.error("--config needs a path"), 1;
98
+ if (cmd === "update")
99
+ return update(dryRun);
55
100
  const env = process.env;
56
101
  const dir = env.OPENCODE_CONFIG_DIR || join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode");
57
102
  const file = resolve(explicit ||
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.d.ts CHANGED
@@ -2,4 +2,5 @@ import type { Plugin } from "@opencode-ai/plugin";
2
2
  import { type SdkAuthStatus } from "@cursor/sdk";
3
3
  export { parseContext } from "./models.js";
4
4
  export declare function keyProblem(status: SdkAuthStatus, nowMs: number): string | undefined;
5
+ export declare function _resetKeyCache(): void;
5
6
  export declare const CursorAuthPlugin: Plugin;
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;
@@ -48,10 +52,17 @@ async function bodyText(input, init) {
48
52
  return input.text();
49
53
  return "";
50
54
  }
55
+ export function _resetKeyCache() {
56
+ keyStatus = undefined;
57
+ warnedExpiry = false;
58
+ }
51
59
  let pastedApiKey;
60
+ const EMPTY_KEY = "Cursor API key is empty — run `opencode auth login` and pick Cursor, or paste a key from cursor.com/dashboard";
52
61
  async function cursorFetch(input, init) {
53
62
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
54
63
  if (url.includes("/chat/completions")) {
64
+ if (pastedApiKey === "")
65
+ return errorResponse({ message: EMPTY_KEY, code: "invalid_api_key" }, 401);
55
66
  const problem = await checkKey();
56
67
  if (problem)
57
68
  return errorResponse(problem, 401);
@@ -87,7 +98,7 @@ export const CursorAuthPlugin = async (input) => {
87
98
  const cursor = (providers["cursor"] ??= { models: {} });
88
99
  cursor.npm ??= "@ai-sdk/openai-compatible";
89
100
  cursor.name ??= "Cursor";
90
- cursor.options = { ...cursor.options, baseURL: BASE_URL };
101
+ cursor.options = { headerTimeout: STREAM_TIMEOUT_MS, chunkTimeout: STREAM_TIMEOUT_MS, ...cursor.options, baseURL: BASE_URL };
91
102
  cursor.models ??= {};
92
103
  for (const [id, { name, context, variants }] of Object.entries(await currentModels())) {
93
104
  cursor.models[id] ??= { name };
@@ -98,6 +109,11 @@ export const CursorAuthPlugin = async (input) => {
98
109
  model.variants ??= Object.fromEntries(Object.entries(variants).map(([n, v]) => [n, { cursor_params: v.params }]));
99
110
  }
100
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
+ },
101
117
  "chat.headers": async (input, output) => {
102
118
  if (input.model.providerID === "cursor")
103
119
  output.headers["x-opencode-session"] = input.sessionID;
@@ -106,8 +122,7 @@ export const CursorAuthPlugin = async (input) => {
106
122
  provider: "cursor",
107
123
  loader: async (getAuth) => {
108
124
  const auth = await getAuth().catch(() => undefined);
109
- if (auth && auth.type === "api" && auth.key !== "cursor-sdk-store")
110
- pastedApiKey = auth.key;
125
+ pastedApiKey = auth?.type === "api" && auth.key !== "cursor-sdk-store" ? (auth.key ?? "").trim() : undefined;
111
126
  return {
112
127
  apiKey: "cursor-managed",
113
128
  baseURL: BASE_URL,
package/installation.md CHANGED
@@ -101,7 +101,13 @@ Optional: to see which models the account is actually allowed to use, clone the
101
101
 
102
102
  ## Upgrading
103
103
 
104
- With `@latest` in the `plugin` array, restarting opencode picks up new versions. If it stays on an old version, clear the cached package and restart opencode:
104
+ opencode resolves `@latest` once, at first install, and keeps that cached version forever; restarting does not upgrade it. To upgrade, run:
105
+
106
+ ```bash
107
+ npx @vidit19sharma/opencode-cursor-auth update
108
+ ```
109
+
110
+ Then tell the user to restart opencode; it installs the current release from npm on start. `update --dry-run` shows what would be removed. Manual equivalent (also delete any other `@vidit19sharma/opencode-cursor-auth@*` dir there, and under `~/.cache/opencode/npm/` on older opencode):
105
111
 
106
112
  ```bash
107
113
  rm -rf ~/.cache/opencode/packages/@vidit19sharma/opencode-cursor-auth@latest
@@ -118,6 +124,7 @@ The plugin is installed. Every model the user's Cursor plan includes is availabl
118
124
  - **403 `permission_error` "Model Blocked"**: a team admin disabled that model for the account. It is hidden from `opencode models` from then on; delete `~/.cache/opencode-cursor-auth/models.json` to re-check.
119
125
  - **400 `Cannot use this model`**: the model id is not in Cursor's catalog for this account.
120
126
  - **Request hangs with no error**: `opencode auth login` was never run for Cursor; run it (0.3.2+ seeds the entry automatically so you get a 401 message instead).
127
+ - **Features missing / variants not showing on Ctrl+T**: opencode is running a stale cached version. Run `npx @vidit19sharma/opencode-cursor-auth update`, then restart opencode.
121
128
  - **Model list empty or only `cursor/auto`**: the catalog fetch failed (offline or not logged in). Fix auth, delete `~/.cache/opencode-cursor-auth/models.json`, restart opencode.
122
129
 
123
130
  ### Paths
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vidit19sharma/opencode-cursor-auth",
3
- "version": "0.3.2",
4
- "description": "OpenCode plugin that uses your Cursor subscription via the official @cursor/sdk \u2014 no proxy server, no separate login.",
3
+ "version": "0.3.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",
7
7
  "types": "dist/index.d.ts",
@@ -39,7 +39,8 @@
39
39
  "spike": "bun spike/validate-sdk.ts",
40
40
  "smoke": "bash scripts/fresh-home-smoke.sh",
41
41
  "prepublishOnly": "npm run build",
42
- "test:cli": "npm run build && bash spike/cli-setup.test.sh"
42
+ "test:cli": "npm run build && bash spike/cli-setup.test.sh",
43
+ "test:plugin": "bun test spike/plugin.test.ts"
43
44
  },
44
45
  "dependencies": {
45
46
  "@cursor/sdk": "^1.0.36"