@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 +28 -0
- package/README.md +20 -5
- package/dist/bridge.d.ts +22 -0
- package/dist/bridge.js +280 -73
- package/dist/cli.js +49 -4
- package/dist/models.js +45 -18
- package/dist/plugin.d.ts +1 -0
- package/dist/plugin.js +19 -4
- package/installation.md +8 -1
- package/package.json +4 -3
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"]`).
|
|
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
|
|
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,
|
|
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
|
|
19
|
-
// console
|
|
20
|
-
// the
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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 =
|
|
158
|
+
const i = queue.indexOf(go);
|
|
117
159
|
if (i >= 0)
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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 ?? "",
|
|
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
|
-
|
|
332
|
-
|
|
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
|
|
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
|
|
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)
|
|
451
|
+
evicted.push(sessions.get(oldestKey));
|
|
356
452
|
sessions.delete(oldestKey);
|
|
357
453
|
}
|
|
358
|
-
for (const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
512
|
-
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
|
+
}
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
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.
|
|
673
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "OpenCode plugin that uses your Cursor subscription via the official @cursor/sdk
|
|
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"
|