@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 +14 -0
- package/README.md +8 -5
- package/dist/bridge.d.ts +20 -0
- package/dist/bridge.js +231 -64
- package/dist/models.js +45 -18
- package/dist/plugin.js +11 -2
- package/package.json +1 -1
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"]`).
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
158
|
+
const i = queue.indexOf(go);
|
|
132
159
|
if (i >= 0)
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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 ?? "",
|
|
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
|
-
|
|
347
|
-
|
|
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
|
|
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
|
|
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)
|
|
451
|
+
evicted.push(sessions.get(oldestKey));
|
|
371
452
|
sessions.delete(oldestKey);
|
|
372
453
|
}
|
|
373
|
-
for (const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
533
|
-
held
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
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.
|
|
703
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
72
|
-
|
|
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 &&
|
|
113
|
+
if (cached && cached.fetchedAt >= fetchedAt) {
|
|
78
114
|
catalog = cached.models;
|
|
79
115
|
fetchedAt = cached.fetchedAt;
|
|
80
116
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
"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",
|