@vidit19sharma/opencode-cursor-auth 0.1.0 → 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/README.md +62 -8
- package/dist/bridge.d.ts +13 -1
- package/dist/bridge.js +371 -112
- package/dist/index.d.ts +3 -0
- package/dist/index.js +45 -35
- package/dist/models.d.ts +13 -0
- package/dist/models.js +115 -0
- package/package.json +2 -1
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/README.md
CHANGED
|
@@ -10,9 +10,14 @@ The plugin bridges opencode's OpenAI-style chat requests to the official [`@curs
|
|
|
10
10
|
- **In-process bridge** — no local HTTP proxy, no port to collide on, no extra daemon
|
|
11
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
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, so
|
|
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
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** — fetched live from `Cursor.models.list()`, cached 24h
|
|
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
|
|
16
21
|
- **Zero cost display** — models are marked cost-free since you're on subscription billing
|
|
17
22
|
|
|
18
23
|
## Requirements
|
|
@@ -46,6 +51,8 @@ Pick **Cursor**, then either:
|
|
|
46
51
|
|
|
47
52
|
Alternatively set `CURSOR_API_KEY` in your environment.
|
|
48
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
|
+
|
|
49
56
|
## Use
|
|
50
57
|
|
|
51
58
|
```sh
|
|
@@ -56,6 +63,35 @@ opencode run -m cursor/auto "hello"
|
|
|
56
63
|
|
|
57
64
|
Or pick any `cursor/*` model from the opencode model selector.
|
|
58
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
|
+
|
|
59
95
|
## How it works
|
|
60
96
|
|
|
61
97
|
```
|
|
@@ -65,25 +101,43 @@ opencode ──(OpenAI chat JSON)──> plugin fetch() ──> @cursor/sdk Agen
|
|
|
65
101
|
|
|
66
102
|
- opencode calls the provider's `fetch` — the plugin intercepts it in-process; `http://cursor.local/v1` is a dummy URL that never resolves.
|
|
67
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.
|
|
68
|
-
- Conversation prefixes 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`.
|
|
69
|
-
- System messages are written to `AGENTS.md` in a per-session temp workspace and loaded via Cursor's project rules (`settingSources: ["project"]`).
|
|
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]`.
|
|
70
122
|
|
|
71
123
|
## Development
|
|
72
124
|
|
|
73
125
|
```sh
|
|
74
126
|
bun install
|
|
75
127
|
bun run build # tsc → dist/
|
|
76
|
-
bun run test # end-to-end bridge tests (requires Cursor login)
|
|
128
|
+
bun run test # end-to-end bridge tests (requires Cursor login, uses real Cursor usage)
|
|
77
129
|
bun run spike # raw @cursor/sdk validation
|
|
78
130
|
```
|
|
79
131
|
|
|
80
|
-
The test suite
|
|
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.
|
|
81
133
|
|
|
82
134
|
## Known limitations
|
|
83
135
|
|
|
84
|
-
- `systemPrompt` as a true harness replacement is feature-gated server-side by Cursor; system text is applied as a project rule (merged under Cursor's harness prompt).
|
|
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).
|
|
85
137
|
- The SDK spawns a `cursor-agent` worker per turn (~1–2 s startup on resume).
|
|
86
|
-
-
|
|
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.
|
|
87
141
|
|
|
88
142
|
## License
|
|
89
143
|
|
package/dist/bridge.d.ts
CHANGED
|
@@ -28,6 +28,18 @@ type OaBody = {
|
|
|
28
28
|
tools?: OaTool[];
|
|
29
29
|
stream?: boolean;
|
|
30
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;
|
|
31
35
|
export declare function _resetForTest(): void;
|
|
32
|
-
export declare function
|
|
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>;
|
|
33
45
|
export {};
|
package/dist/bridge.js
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
* next HTTP request (role:"tool" messages) resolves the parked promises and
|
|
9
9
|
* the SAME run continues — one metered Cursor run per opencode tool loop.
|
|
10
10
|
*/
|
|
11
|
-
import { Agent } from "@cursor/sdk";
|
|
11
|
+
import { Agent, AuthenticationError, ConfigurationError, NetworkError, RateLimitError } from "@cursor/sdk";
|
|
12
12
|
import { createHash, randomUUID } from "node:crypto";
|
|
13
13
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
15
15
|
import { homedir, tmpdir } from "node:os";
|
|
16
16
|
import { join } from "node:path";
|
|
17
|
+
import { markBlocked, resolveModel } from "./models.js";
|
|
17
18
|
// @cursor/sdk's internal logger writes "HH:MM:SS.mmm INFO ..." lines via
|
|
18
19
|
// console.log unconditionally (no config/env to silence it, verified against
|
|
19
20
|
// the esm bundle). Filter exactly those lines, pass everything else through.
|
|
@@ -31,6 +32,111 @@ function textOf(content) {
|
|
|
31
32
|
return "";
|
|
32
33
|
return content.map((p) => p.text ?? "").join("");
|
|
33
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
|
+
}
|
|
34
140
|
function chunk(id, model, delta, finish, usage) {
|
|
35
141
|
return {
|
|
36
142
|
id,
|
|
@@ -41,7 +147,7 @@ function chunk(id, model, delta, finish, usage) {
|
|
|
41
147
|
...(usage ? { usage } : {}),
|
|
42
148
|
};
|
|
43
149
|
}
|
|
44
|
-
function sseResponse(model) {
|
|
150
|
+
function sseResponse(model, onCancel) {
|
|
45
151
|
const id = `chatcmpl-${randomUUID()}`;
|
|
46
152
|
const enc = new TextEncoder();
|
|
47
153
|
let controller;
|
|
@@ -53,19 +159,31 @@ function sseResponse(model) {
|
|
|
53
159
|
},
|
|
54
160
|
cancel() {
|
|
55
161
|
closed = true;
|
|
162
|
+
onCancel();
|
|
56
163
|
},
|
|
57
164
|
});
|
|
165
|
+
function frame(payload) {
|
|
166
|
+
if (closed)
|
|
167
|
+
return;
|
|
168
|
+
controller.enqueue(enc.encode(`data: ${JSON.stringify(payload)}\n\n`));
|
|
169
|
+
}
|
|
58
170
|
function send(delta, finish = null, usage) {
|
|
171
|
+
frame(chunk(id, model, delta, finish, usage));
|
|
172
|
+
}
|
|
173
|
+
function close() {
|
|
59
174
|
if (closed)
|
|
60
175
|
return;
|
|
61
|
-
|
|
176
|
+
closed = true;
|
|
177
|
+
try {
|
|
178
|
+
controller.close();
|
|
179
|
+
}
|
|
180
|
+
catch { }
|
|
62
181
|
}
|
|
63
182
|
function end() {
|
|
64
183
|
if (closed)
|
|
65
184
|
return;
|
|
66
185
|
controller.enqueue(enc.encode("data: [DONE]\n\n"));
|
|
67
|
-
|
|
68
|
-
closed = true;
|
|
186
|
+
close();
|
|
69
187
|
}
|
|
70
188
|
const sink = {
|
|
71
189
|
text: (t) => send({ content: t }),
|
|
@@ -83,10 +201,10 @@ function sseResponse(model) {
|
|
|
83
201
|
end();
|
|
84
202
|
},
|
|
85
203
|
fail: (err) => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
end();
|
|
204
|
+
frame(errorBody(err));
|
|
205
|
+
close();
|
|
89
206
|
},
|
|
207
|
+
close,
|
|
90
208
|
};
|
|
91
209
|
return {
|
|
92
210
|
response: new Response(stream, {
|
|
@@ -96,13 +214,11 @@ function sseResponse(model) {
|
|
|
96
214
|
sink,
|
|
97
215
|
};
|
|
98
216
|
}
|
|
99
|
-
function jsonCollector(model) {
|
|
217
|
+
function jsonCollector(model, resolve) {
|
|
100
218
|
const id = `chatcmpl-${randomUUID()}`;
|
|
101
219
|
let content = "";
|
|
102
220
|
let calls = [];
|
|
103
|
-
|
|
104
|
-
const promise = new Promise((r) => (resolve = r));
|
|
105
|
-
const sink = {
|
|
221
|
+
return {
|
|
106
222
|
text: (t) => (content += t),
|
|
107
223
|
reasoning: () => { },
|
|
108
224
|
toolCalls: (c) => (calls = calls.concat(c)),
|
|
@@ -132,9 +248,18 @@ function jsonCollector(model) {
|
|
|
132
248
|
],
|
|
133
249
|
usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
134
250
|
})),
|
|
135
|
-
fail: (err) => resolve(
|
|
251
|
+
fail: (err) => resolve(errorResponse(err)),
|
|
252
|
+
close: () => { },
|
|
136
253
|
};
|
|
137
|
-
|
|
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 };
|
|
138
263
|
}
|
|
139
264
|
const BATCH_SETTLE_MS = 300;
|
|
140
265
|
const TOOL_RESULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -144,13 +269,33 @@ const DISK_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
144
269
|
const MAX_SESSIONS = 8;
|
|
145
270
|
const SESSION_STORE_DIR = join(homedir(), ".cache", "opencode-cursor-auth");
|
|
146
271
|
const SESSION_STORE_PATH = join(SESSION_STORE_DIR, "sessions.json");
|
|
272
|
+
const TMP_CWD_PREFIX = join(tmpdir(), "opencode-cursor-");
|
|
147
273
|
const sessions = new Map();
|
|
148
274
|
const heldByAgent = new Map();
|
|
149
275
|
const agentByToolCallId = new Map();
|
|
150
276
|
let diskSessions = new Map();
|
|
151
277
|
let sessionsLoaded = false;
|
|
152
|
-
|
|
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) {
|
|
153
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]);
|
|
154
299
|
return createHash("sha256").update(JSON.stringify(norm)).digest("hex");
|
|
155
300
|
}
|
|
156
301
|
function collectDiskEntries() {
|
|
@@ -176,6 +321,9 @@ async function loadSessions() {
|
|
|
176
321
|
if (now - v.lastUsedAt <= DISK_SESSION_TTL_MS)
|
|
177
322
|
diskSessions.set(k, v);
|
|
178
323
|
}
|
|
324
|
+
for (const v of Object.values(raw.entries))
|
|
325
|
+
if (now - v.lastUsedAt > DISK_SESSION_TTL_MS)
|
|
326
|
+
releaseCwd(v.cwd);
|
|
179
327
|
}
|
|
180
328
|
catch { }
|
|
181
329
|
}
|
|
@@ -185,9 +333,17 @@ function saveSessions() {
|
|
|
185
333
|
}
|
|
186
334
|
function sweepSessions(save = true) {
|
|
187
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
|
+
}
|
|
188
342
|
for (const [k, v] of sessions)
|
|
189
|
-
if (now - v.lastUsedAt > SESSION_TTL_MS)
|
|
343
|
+
if (now - v.lastUsedAt > SESSION_TTL_MS) {
|
|
190
344
|
sessions.delete(k);
|
|
345
|
+
evicted.push(v.cwd);
|
|
346
|
+
}
|
|
191
347
|
while (sessions.size > MAX_SESSIONS) {
|
|
192
348
|
let oldestKey = "";
|
|
193
349
|
let oldest = Infinity;
|
|
@@ -196,8 +352,11 @@ function sweepSessions(save = true) {
|
|
|
196
352
|
oldest = v.lastUsedAt;
|
|
197
353
|
oldestKey = k;
|
|
198
354
|
}
|
|
355
|
+
evicted.push(sessions.get(oldestKey).cwd);
|
|
199
356
|
sessions.delete(oldestKey);
|
|
200
357
|
}
|
|
358
|
+
for (const cwd of evicted)
|
|
359
|
+
releaseCwd(cwd);
|
|
201
360
|
if (save)
|
|
202
361
|
saveSessions();
|
|
203
362
|
}
|
|
@@ -214,15 +373,18 @@ function rememberSession(held, toolCalls) {
|
|
|
214
373
|
? { tool_calls: toolCalls.map((c) => ({ id: c.id, function: { name: c.name, arguments: c.argumentsJson } })) }
|
|
215
374
|
: {}),
|
|
216
375
|
};
|
|
217
|
-
const key = hashMessages(held.messages.concat(assistant));
|
|
376
|
+
const key = hashMessages(held.messages.concat(assistant), held.scope);
|
|
218
377
|
const entry = {
|
|
219
378
|
agentId: held.agent.agentId,
|
|
220
379
|
cwd: held.cwd,
|
|
221
380
|
lastUsedAt: Date.now(),
|
|
222
381
|
};
|
|
382
|
+
const displaced = sessions.get(key) ?? diskSessions.get(key);
|
|
223
383
|
sessions.set(key, entry);
|
|
224
384
|
diskSessions.set(key, entry);
|
|
225
385
|
sweepSessions();
|
|
386
|
+
if (displaced && displaced.cwd !== entry.cwd)
|
|
387
|
+
releaseCwd(displaced.cwd);
|
|
226
388
|
}
|
|
227
389
|
async function syncAgentsMd(cwd, messages) {
|
|
228
390
|
mkdirSync(cwd, { recursive: true });
|
|
@@ -242,7 +404,12 @@ function mapUsage(u) {
|
|
|
242
404
|
return undefined;
|
|
243
405
|
const prompt = u.inputTokens ?? 0;
|
|
244
406
|
const completion = u.outputTokens ?? 0;
|
|
245
|
-
|
|
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;
|
|
246
413
|
}
|
|
247
414
|
// deltas arriving between tool flush and the continuation request get buffered
|
|
248
415
|
function emit(held, kind, t) {
|
|
@@ -251,73 +418,124 @@ function emit(held, kind, t) {
|
|
|
251
418
|
else
|
|
252
419
|
held.buffer.push({ kind, t });
|
|
253
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
|
+
}
|
|
254
428
|
function attachSink(held, sink) {
|
|
255
429
|
held.sink = sink;
|
|
430
|
+
if (held.buffer.length)
|
|
431
|
+
openReply(held);
|
|
256
432
|
for (const b of held.buffer)
|
|
257
433
|
(b.kind === "text" ? sink.text : sink.reasoning)(b.t);
|
|
258
434
|
held.buffer = [];
|
|
259
435
|
}
|
|
260
|
-
function
|
|
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) {
|
|
261
452
|
held.done = true;
|
|
453
|
+
if (held.slot) {
|
|
454
|
+
held.slot = false;
|
|
455
|
+
releaseSlot();
|
|
456
|
+
}
|
|
457
|
+
held.detachSignal?.();
|
|
458
|
+
held.detachSignal = null;
|
|
262
459
|
if (held.flushTimer)
|
|
263
460
|
clearTimeout(held.flushTimer);
|
|
264
461
|
if (held.idleTimer)
|
|
265
462
|
clearTimeout(held.idleTimer);
|
|
266
463
|
for (const id of held.parked.keys())
|
|
267
464
|
agentByToolCallId.delete(id);
|
|
268
|
-
heldByAgent.delete(agentId);
|
|
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);
|
|
269
491
|
}
|
|
270
492
|
/** Model requested tools: emit tool_calls, end this HTTP segment, park the run. */
|
|
271
|
-
function flushToolCalls(
|
|
493
|
+
function flushToolCalls(held) {
|
|
272
494
|
held.flushTimer = null;
|
|
273
495
|
if (held.done || !held.pendingCalls.length)
|
|
274
496
|
return;
|
|
275
497
|
const calls = held.pendingCalls;
|
|
276
498
|
held.pendingCalls = [];
|
|
277
499
|
for (const c of calls)
|
|
278
|
-
agentByToolCallId.set(c.id, agentId);
|
|
500
|
+
agentByToolCallId.set(c.id, held.agent.agentId);
|
|
279
501
|
const sink = held.sink;
|
|
280
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);
|
|
281
508
|
sink?.toolCalls(calls);
|
|
282
509
|
sink?.finish("tool_calls");
|
|
283
510
|
rememberSession(held, calls);
|
|
284
|
-
held.idleTimer = setTimeout(() =>
|
|
285
|
-
teardownHeld(agentId, held);
|
|
286
|
-
held.run.cancel().catch(() => { });
|
|
287
|
-
try {
|
|
288
|
-
held.agent.close();
|
|
289
|
-
}
|
|
290
|
-
catch { }
|
|
291
|
-
}, TOOL_RESULT_TIMEOUT_MS);
|
|
511
|
+
held.idleTimer = setTimeout(() => abortHeld(held), TOOL_RESULT_TIMEOUT_MS);
|
|
292
512
|
held.idleTimer.unref?.();
|
|
293
513
|
}
|
|
294
|
-
async function wireRunCompletion(
|
|
295
|
-
if (held.wired)
|
|
514
|
+
async function wireRunCompletion(held) {
|
|
515
|
+
if (held.wired || !held.run)
|
|
296
516
|
return;
|
|
297
517
|
held.wired = true;
|
|
518
|
+
let result;
|
|
298
519
|
try {
|
|
299
|
-
|
|
300
|
-
if (held.done)
|
|
301
|
-
return;
|
|
302
|
-
const usage = mapUsage(result.usage) ?? held.usage;
|
|
303
|
-
teardownHeld(agentId, held);
|
|
304
|
-
rememberSession(held, []);
|
|
305
|
-
held.sink?.finish("stop", usage);
|
|
306
|
-
try {
|
|
307
|
-
held.agent.close();
|
|
308
|
-
}
|
|
309
|
-
catch { }
|
|
520
|
+
result = await held.run.wait();
|
|
310
521
|
}
|
|
311
522
|
catch (err) {
|
|
312
|
-
if (held.done)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
held.sink?.fail(err);
|
|
316
|
-
try {
|
|
317
|
-
held.agent.close();
|
|
318
|
-
}
|
|
319
|
-
catch { }
|
|
523
|
+
if (!held.done)
|
|
524
|
+
failHeld(held, err);
|
|
525
|
+
return;
|
|
320
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);
|
|
321
539
|
}
|
|
322
540
|
export function _resetForTest() {
|
|
323
541
|
sessions.clear();
|
|
@@ -326,79 +544,106 @@ export function _resetForTest() {
|
|
|
326
544
|
agentByToolCallId.clear();
|
|
327
545
|
sessionsLoaded = false;
|
|
328
546
|
}
|
|
329
|
-
export
|
|
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");
|
|
330
560
|
await loadSessions();
|
|
331
561
|
const streaming = body.stream !== false;
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
const
|
|
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);
|
|
336
566
|
// continuation: trailing tool results for a parked run
|
|
337
567
|
const lastMsg = body.messages[body.messages.length - 1];
|
|
338
|
-
if (lastMsg?.role === "tool" && lastMsg.tool_call_id
|
|
568
|
+
if (lastMsg?.role === "tool" && lastMsg.tool_call_id) {
|
|
339
569
|
const agentId = agentByToolCallId.get(lastMsg.tool_call_id);
|
|
340
|
-
|
|
341
|
-
if (held
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
held.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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));
|
|
356
591
|
}
|
|
357
|
-
return reply();
|
|
358
592
|
}
|
|
359
|
-
|
|
360
|
-
return reply();
|
|
593
|
+
return reply.promise;
|
|
361
594
|
}
|
|
362
595
|
// new turn: reuse the agent whose last-seen conversation matches our prefix
|
|
363
596
|
const prefix = body.messages.slice(0, -1);
|
|
364
|
-
const key = hashMessages(prefix);
|
|
597
|
+
const key = hashMessages(prefix, scope);
|
|
598
|
+
await acquireSlot(signal);
|
|
365
599
|
const entry = sessions.get(key) ?? diskSessions.get(key);
|
|
366
600
|
let agent;
|
|
367
601
|
let cwd;
|
|
368
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
|
+
});
|
|
369
609
|
if (entry) {
|
|
370
610
|
sessions.delete(key);
|
|
611
|
+
diskSessions.delete(key);
|
|
371
612
|
cwd = entry.cwd;
|
|
372
|
-
agent = await Agent.resume(entry.agentId, {
|
|
373
|
-
model: { id: body.model },
|
|
374
|
-
tools: ["mcp"],
|
|
375
|
-
...(apiKey ? { apiKey } : {}),
|
|
376
|
-
local: { cwd, settingSources: ["project"], enableAgentRetries: true },
|
|
377
|
-
}).catch(() => {
|
|
378
|
-
sessions.delete(key);
|
|
379
|
-
diskSessions.delete(key);
|
|
613
|
+
agent = await withRetry(() => Agent.resume(entry.agentId, agentOptions(cwd)), signal).catch(() => {
|
|
380
614
|
saveSessions();
|
|
615
|
+
releaseCwd(entry.cwd);
|
|
381
616
|
return undefined;
|
|
382
617
|
});
|
|
383
618
|
if (agent)
|
|
384
619
|
newSuffix = body.messages.slice(-1);
|
|
385
620
|
}
|
|
386
621
|
if (!agent) {
|
|
387
|
-
cwd = await mkdtemp(
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
+
}
|
|
394
633
|
}
|
|
395
|
-
|
|
634
|
+
held = {
|
|
396
635
|
agent,
|
|
397
636
|
cwd: cwd,
|
|
398
|
-
|
|
637
|
+
model: body.model,
|
|
638
|
+
run: null,
|
|
399
639
|
messages: body.messages,
|
|
640
|
+
scope,
|
|
400
641
|
textAcc: "",
|
|
401
642
|
sink: null,
|
|
643
|
+
reply,
|
|
644
|
+
response: sse?.response ?? null,
|
|
645
|
+
opened: false,
|
|
646
|
+
detachSignal: null,
|
|
402
647
|
buffer: [],
|
|
403
648
|
pendingCalls: [],
|
|
404
649
|
parked: new Map(),
|
|
@@ -406,9 +651,12 @@ export async function handleChatCompletion(body, apiKey) {
|
|
|
406
651
|
idleTimer: null,
|
|
407
652
|
wired: false,
|
|
408
653
|
done: false,
|
|
654
|
+
slot: true,
|
|
409
655
|
};
|
|
656
|
+
const self = held;
|
|
657
|
+
heldByAgent.set(agent.agentId, held);
|
|
658
|
+
attachSignal(held, signal);
|
|
410
659
|
attachSink(held, sink);
|
|
411
|
-
const agentIdRef = { id: "" };
|
|
412
660
|
const customTools = {};
|
|
413
661
|
for (const t of body.tools ?? []) {
|
|
414
662
|
customTools[t.function.name] = {
|
|
@@ -416,11 +664,11 @@ export async function handleChatCompletion(body, apiKey) {
|
|
|
416
664
|
inputSchema: t.function.parameters ?? { type: "object", properties: {} },
|
|
417
665
|
execute: (args, ctx) => {
|
|
418
666
|
const id = (ctx.toolCallId ?? `call_${randomUUID()}`).replace(/\s+/g, "_");
|
|
419
|
-
|
|
420
|
-
if (
|
|
421
|
-
clearTimeout(
|
|
422
|
-
|
|
423
|
-
return new Promise((resolve) =>
|
|
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));
|
|
424
672
|
},
|
|
425
673
|
};
|
|
426
674
|
}
|
|
@@ -430,29 +678,40 @@ export async function handleChatCompletion(body, apiKey) {
|
|
|
430
678
|
.map((m) => (m.role === "user" ? textOf(m.content) : `${m.role.toUpperCase()}: ${textOf(m.content)}`))
|
|
431
679
|
.filter(Boolean)
|
|
432
680
|
.join("\n\n") || "(empty message)";
|
|
681
|
+
let run;
|
|
433
682
|
try {
|
|
434
|
-
|
|
683
|
+
run = await withRetry(() => agent.send(prompt, {
|
|
435
684
|
onDelta: ({ update }) => {
|
|
685
|
+
if (self.done)
|
|
686
|
+
return;
|
|
436
687
|
if (update.type === "text-delta" && update.text) {
|
|
437
|
-
|
|
438
|
-
|
|
688
|
+
openReply(self);
|
|
689
|
+
self.textAcc += update.text;
|
|
690
|
+
emit(self, "text", update.text);
|
|
439
691
|
}
|
|
440
692
|
else if (update.type === "thinking-delta" && update.text) {
|
|
441
|
-
|
|
693
|
+
openReply(self);
|
|
694
|
+
emit(self, "reasoning", update.text);
|
|
442
695
|
}
|
|
443
696
|
else if (update.type === "turn-ended") {
|
|
444
|
-
|
|
697
|
+
self.usage = mapUsage(update.usage);
|
|
445
698
|
}
|
|
446
699
|
},
|
|
447
700
|
local: { customTools: customTools },
|
|
448
|
-
});
|
|
701
|
+
}), signal, 1000, (err) => !self.opened && !self.done && isTransient(err));
|
|
449
702
|
}
|
|
450
703
|
catch (err) {
|
|
451
|
-
|
|
452
|
-
|
|
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;
|
|
453
711
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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;
|
|
458
717
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
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;
|
|
2
5
|
export declare const CursorAuthPlugin: Plugin;
|
|
3
6
|
export default CursorAuthPlugin;
|
package/dist/index.js
CHANGED
|
@@ -1,41 +1,39 @@
|
|
|
1
1
|
import { Cursor } from "@cursor/sdk";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
import { handleChatCompletion } from "./bridge.js";
|
|
2
|
+
import { errorResponse, handleChatCompletion } from "./bridge.js";
|
|
3
|
+
import { currentModels } from "./models.js";
|
|
4
|
+
export { parseContext } from "./models.js";
|
|
6
5
|
const BASE_URL = "http://cursor.local/v1";
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
// no cache yet, or unreadable — fall back
|
|
18
|
-
}
|
|
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`;
|
|
19
14
|
return undefined;
|
|
20
15
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
models[m.id === "default" ? "auto" : m.id] = m.displayName ?? m.id;
|
|
32
|
-
mkdirSync(CACHE_DIR, { recursive: true });
|
|
33
|
-
writeFileSync(MODELS_CACHE_PATH, JSON.stringify({ version: 1, fetchedAt: Date.now(), models }));
|
|
34
|
-
return models;
|
|
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) };
|
|
35
26
|
}
|
|
36
|
-
|
|
37
|
-
|
|
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`);
|
|
38
35
|
}
|
|
36
|
+
return keyProblem(status, now);
|
|
39
37
|
}
|
|
40
38
|
async function bodyText(input, init) {
|
|
41
39
|
const b = init?.body;
|
|
@@ -51,8 +49,15 @@ let pastedApiKey;
|
|
|
51
49
|
async function cursorFetch(input, init) {
|
|
52
50
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
53
51
|
if (url.includes("/chat/completions")) {
|
|
52
|
+
const problem = await checkKey();
|
|
53
|
+
if (problem)
|
|
54
|
+
return errorResponse(problem, 401);
|
|
54
55
|
const body = JSON.parse(await bodyText(input, init));
|
|
55
|
-
|
|
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);
|
|
56
61
|
}
|
|
57
62
|
if (url.includes("/models")) {
|
|
58
63
|
return Response.json({
|
|
@@ -71,12 +76,17 @@ export const CursorAuthPlugin = async () => {
|
|
|
71
76
|
cursor.name ??= "Cursor";
|
|
72
77
|
cursor.options = { ...cursor.options, baseURL: BASE_URL };
|
|
73
78
|
cursor.models ??= {};
|
|
74
|
-
for (const [id,
|
|
75
|
-
cursor.models[id] ??= { name
|
|
79
|
+
for (const [id, { name, context }] of Object.entries(await currentModels())) {
|
|
80
|
+
cursor.models[id] ??= { name };
|
|
76
81
|
const model = cursor.models[id];
|
|
77
82
|
model.cost = { input: 0, output: 0, cache_read: 0, cache_write: 0 };
|
|
83
|
+
model.limit ??= { context: context ?? DEFAULT_CONTEXT, output: DEFAULT_OUTPUT };
|
|
78
84
|
}
|
|
79
85
|
},
|
|
86
|
+
"chat.headers": async (input, output) => {
|
|
87
|
+
if (input.model.providerID === "cursor")
|
|
88
|
+
output.headers["x-opencode-session"] = input.sessionID;
|
|
89
|
+
},
|
|
80
90
|
auth: {
|
|
81
91
|
provider: "cursor",
|
|
82
92
|
loader: async (getAuth) => {
|
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,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vidit19sharma/opencode-cursor-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "OpenCode plugin that uses your Cursor subscription via the official @cursor/sdk \u2014 no proxy server, no separate login.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"dist",
|
|
10
10
|
"README.md",
|
|
11
|
+
"CHANGELOG.md",
|
|
11
12
|
"LICENSE"
|
|
12
13
|
],
|
|
13
14
|
"author": "Vidit Sharma <vidit19sharma@gmail.com> (https://github.com/vidit19sharma)",
|