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