cursor-opencode-provider 0.1.4 → 0.2.1

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/README.md CHANGED
@@ -18,7 +18,7 @@ OpenCode driving a Cursor-routed Grok model through this provider:
18
18
  - **Authentication** — browser OAuth (PKCE), or API key from [cursor.com/settings](https://cursor.com/settings)
19
19
  - **Model discovery** — fetches available models from Cursor's API and caches them locally
20
20
  - **Streaming** — bidirectional Connect-RPC stream for agent runs
21
- - **Tool calls** — maps Cursor exec-server messages to AI SDK tool-call parts
21
+ - **Tool calls** — maps Cursor exec-server messages to AI SDK tool-call parts; strips OpenCode's `read` XML envelope (`<path>`/`<content>` + `N:` prefixes) before returning content to Cursor so the model cannot echo the wrapper into writes
22
22
  - **Thinking / reasoning** — surfaces extended-thinking deltas where the model supports it
23
23
 
24
24
  ## Requirements
@@ -47,7 +47,7 @@ Add the package name to OpenCode config. OpenCode installs npm plugins with Bun
47
47
  }
48
48
  ```
49
49
 
50
- Pin a version if you want: `"cursor-opencode-provider@0.1.4"`.
50
+ Pin a version if you want: `"cursor-opencode-provider@0.2.1"`.
51
51
 
52
52
  ### From a local clone
53
53
 
@@ -102,7 +102,7 @@ Choose the **cursor** provider, then one of:
102
102
  | **Cursor account (browser login)** | PKCE OAuth — opens cursor.com to sign in |
103
103
  | **API key** | Paste a key from [cursor.com/settings](https://cursor.com/settings) (`sk-...`) |
104
104
 
105
- After login, the plugin fetches your available models and writes them to `~/.cache/opencode/cursor-models.json` (or `$XDG_CACHE_HOME/opencode/` when set). On later startups, if that cache is missing or empty but Cursor auth is still present, the plugin fetches again during config load.
105
+ After login, the plugin fetches your available models and writes them to `~/.cache/opencode/cursor-models.json` (or `$XDG_CACHE_HOME/opencode/` when set). On later startups, a missing, empty, expired, or old-schema cache is refreshed during config load when Cursor auth is available; an existing stale cache remains usable if refresh fails.
106
106
 
107
107
  ### Paths (XDG)
108
108
 
@@ -114,12 +114,37 @@ After login, the plugin fetches your available models and writes them to `~/.cac
114
114
 
115
115
  ### Select a model
116
116
 
117
- Pick a model from the cached list (for example `composer-2.5`, `default`, or a Claude/GPT variant exposed by your subscription):
117
+ Pick a model from the cached list (for example `composer-2.5`, `default`, or a Claude/GPT model exposed by your subscription):
118
118
 
119
119
  ```bash
120
120
  opencode run --model cursor/composer-2.5 "Hello from Cursor via OpenCode"
121
121
  ```
122
122
 
123
+ #### Variants
124
+
125
+ Cursor models often expose parameterized variants (effort, thinking, fast, context tier, …). The plugin materializes those as OpenCode **model variants**. In the TUI, pick one from the variant dialog or cycle with OpenCode’s `variant_cycle` keybind (default `ctrl+t`).
126
+
127
+ The selected variant’s Cursor parameter map is forwarded on the Run as `requested_model.parameters` (isolated under `providerOptions.cursor.cursorVariantParameters` so unrelated OpenCode options are not leaked onto the wire).
128
+
129
+ #### 1M / long context
130
+
131
+ OpenCode’s context limit is static per model entry, while Cursor’s long-context tier is a variant parameter (`context=1m`). When a model has both a base tier and a `1m` tier, the plugin emits a separate OpenCode entry `<model-id>-1m` (for example `claude-opus-4-8-1m`) with:
132
+
133
+ - `limit.context` set to the 1M window (so overflow checks and compaction match the tier)
134
+ - only the long-context variants in its picker
135
+ - the real Cursor model id carried in `options.cursorModelId` (not `config.id`, which would make OpenCode merge base variants into the 1M entry)
136
+
137
+ The Run still uses Cursor’s original model id; OpenCode’s synthetic `-1m` id is only for picking and limits.
138
+
139
+ #### Max mode
140
+
141
+ Cursor IDE has a separate **Max Mode** toggle that sets `requested_model.max_mode` and selects the default max / 1m variant. OpenCode has no equivalent custom toggle, so this provider approximates it:
142
+
143
+ - Selecting a `*-1m` model (or any resolved params with `context=1m`) sets wire `max_mode` to `true`
144
+ - An explicit `providerOptions.cursor.maxMode` hint also turns it on
145
+
146
+ There is no independent Max Mode chrome in OpenCode beyond choosing the 1M model / long-context variant.
147
+
123
148
  ## Programmatic usage
124
149
 
125
150
  ```ts
@@ -181,6 +206,7 @@ OpenCode
181
206
  | `src/index.ts` | `createCursor` factory; default export is `CursorPlugin` |
182
207
  | `src/language-model.ts` | AI SDK `LanguageModelV3` adapter (`doStream`, `doGenerate`) |
183
208
  | `src/session.ts` | Held-open agent Run session and pending exec correlation |
209
+ | `src/debug.ts` | Opt-in wire-level debug logging (`CURSOR_PROVIDER_DEBUG`) |
184
210
  | `src/auth.ts` | PKCE OAuth, API key exchange, JWT refresh |
185
211
  | `src/models.ts` | `AvailableModels` fetch and `cursor-models.json` cache |
186
212
  | `src/agent-url.ts` | `GetServerConfig` fetch + in-process memo (region-specific Run host) |
package/dist/agent-url.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { fetchAgentUrl, trace } from "./transport/connect.js";
2
+ import { fetchAgentUrl } from "./transport/connect.js";
3
+ import { trace } from "./debug.js";
3
4
  import { CURSOR_API_HOST } from "./shared.js";
4
5
  const DEFAULT_API_BASE = `https://${CURSOR_API_HOST}`;
5
6
  // In-process memo of the region-specific Run stream origin (agentnUrl). Resolved
@@ -0,0 +1 @@
1
+ export declare function trace(msg: string): void;
package/dist/debug.js ADDED
@@ -0,0 +1,22 @@
1
+ import fs from "node:fs";
2
+ // Wire-level diagnostics. Opt in with CURSOR_PROVIDER_DEBUG=1 (or "true").
3
+ // Writes to CURSOR_PROVIDER_DEBUG_FILE (default /tmp/cursor-provider-debug.log).
4
+ // Truncated once per process. Tokens / checksums should be redacted by callers.
5
+ const DEBUG_ENABLED = process.env.CURSOR_PROVIDER_DEBUG === "1" ||
6
+ process.env.CURSOR_PROVIDER_DEBUG === "true";
7
+ const DEBUG_FILE = process.env.CURSOR_PROVIDER_DEBUG_FILE || "/tmp/cursor-provider-debug.log";
8
+ let _traceInitialized = false;
9
+ export function trace(msg) {
10
+ if (!DEBUG_ENABLED)
11
+ return;
12
+ try {
13
+ if (!_traceInitialized) {
14
+ _traceInitialized = true;
15
+ fs.writeFileSync(DEBUG_FILE, `--- cursor-provider debug (pid ${process.pid}) ${new Date().toISOString()} ---\n`);
16
+ }
17
+ fs.appendFileSync(DEBUG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
18
+ }
19
+ catch {
20
+ /* ignore */
21
+ }
22
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
- import { bidiRunStream, normalizeAgentRunOrigin, trace } from "./transport/connect.js";
3
+ import { bidiRunStream, normalizeAgentRunOrigin } from "./transport/connect.js";
4
+ import { trace } from "./debug.js";
4
5
  import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
5
6
  import { decodeFramePayload } from "./protocol/framing.js";
6
7
  import { decodeMessage } from "./protocol/messages.js";
@@ -9,7 +10,7 @@ import { handleKvServerMessage } from "./protocol/kv.js";
9
10
  import { getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
10
11
  import { conversationBlobCount } from "./protocol/blob-store.js";
11
12
  import { sessionManager } from "./session.js";
12
- import { readCache, cacheFilePath, resolveVariantParameters } from "./models.js";
13
+ import { readCache, cacheFilePath, resolveVariantParameters, paramsImplyMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
13
14
  import { buildRequestContext } from "./context/build.js";
14
15
  import { opencodeGlobalCacheDir } from "./context/paths.js";
15
16
  import { resolveAgentUrl } from "./agent-url.js";
@@ -78,6 +79,7 @@ async function doStreamImpl(modelId, options, callOptions) {
78
79
  resultField: pending.resultField,
79
80
  output: r.output,
80
81
  error: r.error,
82
+ toolName: pending.toolName ?? r.toolName,
81
83
  })) {
82
84
  session.stream.write(frame);
83
85
  }
@@ -182,10 +184,24 @@ async function startSession(modelId, token, callOptions, options) {
182
184
  telemetryEnabled: resolveTelemetryEnabled(options),
183
185
  }));
184
186
  const providerOptions = callOptions.providerOptions?.cursor;
185
- const reasoningEffort = providerOptions?.reasoningEffort;
186
- const maxMode = !!(providerOptions?.maxMode ?? false);
187
- const modelInfo = _availableModels?.find((m) => m.id === modelId);
188
- const parameterValues = resolveVariantParameters(modelInfo, { reasoningEffort, maxMode });
187
+ // OpenCode merges model, agent, and selected-variant options before placing
188
+ // them under providerOptions.cursor. Read only the plugin's dedicated nested
189
+ // payload so unrelated options never become requested_model.parameters.
190
+ const picked = extractCursorVariantParameters(providerOptions);
191
+ const cursorModelId = resolveCursorWireModelId(providerOptions, modelId);
192
+ const reasoningEffort = typeof providerOptions?.reasoningEffort === "string"
193
+ ? providerOptions.reasoningEffort
194
+ : undefined;
195
+ const hintMaxMode = !!(providerOptions?.maxMode ?? false);
196
+ const modelInfo = _availableModels?.find((m) => m.id === cursorModelId);
197
+ const parameterValues = resolveVariantParameters(modelInfo, {
198
+ reasoningEffort,
199
+ maxMode: hintMaxMode,
200
+ picked,
201
+ });
202
+ // Wire max_mode from the hint *or* a 1m context pick — OpenCode's variant
203
+ // paramMap does not include a maxMode key when the user selects 1m.
204
+ const maxMode = hintMaxMode || paramsImplyMaxMode(parameterValues);
189
205
  // Do NOT pass callOptions.abortSignal into the h2 Run stream. OpenCode aborts
190
206
  // that signal when a turn ends with tool-calls; the Cursor stream must stay
191
207
  // open until we write the exec results on the next doStream.
@@ -206,7 +222,7 @@ async function startSession(modelId, token, callOptions, options) {
206
222
  const conversationState = getCheckpoint(conversationId);
207
223
  const reqBytes = buildRunRequest({
208
224
  text: userText,
209
- modelId,
225
+ modelId: cursorModelId,
210
226
  conversationId,
211
227
  systemPrompt: conversationState ? undefined : systemPrompt,
212
228
  conversationState,
@@ -225,7 +241,7 @@ async function startSession(modelId, token, callOptions, options) {
225
241
  const hooksCtx = typeof requestContext.hooks_additional_context === "string"
226
242
  ? requestContext.hooks_additional_context
227
243
  : "";
228
- trace(`outbound Run: model=${modelId} conversationId=${conversationId} ` +
244
+ trace(`outbound Run: model=${cursorModelId} opencodeModel=${modelId} conversationId=${conversationId} ` +
229
245
  `params=${JSON.stringify(parameterValues ?? [])} ` +
230
246
  `maxMode=${maxMode} systemPromptLen=${systemPrompt?.length ?? 0} tools=${tools.length} ` +
231
247
  `skills=${skillsCount} hooks=${hooksCtx ? hooksCtx.split("\n").length : 0} ` +
@@ -575,6 +591,7 @@ async function pump(session, controller, ids, abortSignal) {
575
591
  resultField: parsed.resultField,
576
592
  output: "",
577
593
  error: reason,
594
+ toolName: parsed.toolName,
578
595
  })) {
579
596
  session.stream.write(frame);
580
597
  }
@@ -586,7 +603,7 @@ async function pump(session, controller, ids, abortSignal) {
586
603
  }
587
604
  const tc = buildToolCallPart(parsed, session.sessionId);
588
605
  // Keep the stream open; the result arrives on the next doStream call.
589
- sessionManager.registerPending(parsed.id, session, parsed.resultField);
606
+ sessionManager.registerPending(parsed.id, session, parsed.resultField, parsed.toolName);
590
607
  // tc.input is already a JSON string (LanguageModelV3ToolCall.input).
591
608
  trace(`exec: EMITTED tool-call toolCallId=${tc.toolCallId} toolName=${tc.toolName} inputLen=${tc.input.length}`);
592
609
  // Close open text/reasoning spans before tool-call (required by AI SDK V3).
package/dist/models.d.ts CHANGED
@@ -2,6 +2,10 @@ export type ModelParameterValue = {
2
2
  id: string;
3
3
  value: string;
4
4
  };
5
+ /** Dedicated OpenCode provider-option key for a selected Cursor variant. */
6
+ export declare const CURSOR_VARIANT_PARAMETERS_KEY = "cursorVariantParameters";
7
+ /** Real Cursor model id used when an OpenCode entry has a synthetic id. */
8
+ export declare const CURSOR_WIRE_MODEL_ID_KEY = "cursorModelId";
5
9
  export type ModelVariant = {
6
10
  key: string;
7
11
  parameterValues: ModelParameterValue[];
@@ -16,13 +20,27 @@ export type ModelInfo = {
16
20
  supportsThinking?: boolean;
17
21
  supportsAgent?: boolean;
18
22
  maxContext?: number;
23
+ /** Context window when max-mode is on (proto field 16). */
24
+ maxContextForMaxMode?: number;
19
25
  supportsMaxMode?: boolean;
20
26
  variants: ModelVariant[];
21
27
  };
22
28
  export type ModelCache = {
23
29
  models: ModelInfo[];
24
30
  fetchedAt: number;
31
+ /** Absent or mismatched → treat cache as stale (forces AvailableModels refetch). */
32
+ schemaVersion?: number;
25
33
  };
34
+ /**
35
+ * Read only the dedicated variant payload generated by the plugin. OpenCode
36
+ * merges model, agent, and variant options into one providerOptions namespace;
37
+ * treating that whole object as Cursor parameters would leak unrelated options
38
+ * onto the wire.
39
+ */
40
+ export declare function extractCursorVariantParameters(providerOptions: Record<string, unknown> | undefined): ModelParameterValue[] | undefined;
41
+ export declare function resolveCursorWireModelId(providerOptions: Record<string, unknown> | undefined, fallback: string): string;
42
+ /** True when resolved params select the long-context (1m) tier. */
43
+ export declare function paramsImplyMaxMode(params: ModelParameterValue[]): boolean;
26
44
  export declare function isCacheFresh(cache: ModelCache, ttlMs?: number): boolean;
27
45
  export declare function cacheFilePath(cacheDir: string): string;
28
46
  export declare function readCache(cacheDir: string): Promise<ModelCache | null>;
@@ -30,14 +48,21 @@ export declare function writeCache(cacheDir: string, cache: ModelCache): Promise
30
48
  export declare function mapAvailableModelsResponse(raw: Record<string, unknown>): ModelInfo[];
31
49
  /**
32
50
  * Resolve the parameter values to send in `requested_model.parameters` for a
33
- * given model + requested reasoning effort. Each variant already carries the
34
- * full `{id,value}` set (per-model vocabulary), so we pick the matching variant
35
- * and return its parameters verbatim the client never constructs these by
36
- * hand.
51
+ * given model + the variant opencode selected. Each variant already carries
52
+ * the full `{id,value}` set (per-model vocabulary effort, fast, thinking,
53
+ * context, …), so we pick the matching variant and return its parameters
54
+ * verbatim — the client never constructs these by hand.
55
+ *
56
+ * `picked` is the user-selected variant paramMap (opencode sends it under
57
+ * `providerOptions.cursor`); when present it wins over effort/maxMode hints
58
+ * so every param the user chose (context, fast, …) is forwarded to Cursor.
59
+ * Hints (`reasoningEffort`, `maxMode`) live beside the dedicated picked array,
60
+ * so the picked values are already isolated and can be matched verbatim.
37
61
  */
38
62
  export declare function resolveVariantParameters(model: ModelInfo | undefined, opts?: {
39
63
  reasoningEffort?: string;
40
64
  maxMode?: boolean;
65
+ picked?: ModelParameterValue[];
41
66
  }): ModelParameterValue[];
42
67
  export declare function fetchModels(token: string, options?: {
43
68
  baseURL?: string;
package/dist/models.js CHANGED
@@ -1,11 +1,69 @@
1
- import { MODEL_CACHE_FILE, MODEL_CACHE_TTL_MS } from "./shared.js";
1
+ import { MODEL_CACHE_FILE, MODEL_CACHE_SCHEMA_VERSION, MODEL_CACHE_TTL_MS } from "./shared.js";
2
2
  import { unaryAvailableModels } from "./transport/connect.js";
3
3
  import { buildRequestedModelParams } from "./protocol/thinking.js";
4
4
  import { readFile, writeFile, mkdir } from "node:fs/promises";
5
5
  import { existsSync } from "node:fs";
6
6
  import path from "node:path";
7
+ /** Dedicated OpenCode provider-option key for a selected Cursor variant. */
8
+ export const CURSOR_VARIANT_PARAMETERS_KEY = "cursorVariantParameters";
9
+ /** Real Cursor model id used when an OpenCode entry has a synthetic id. */
10
+ export const CURSOR_WIRE_MODEL_ID_KEY = "cursorModelId";
11
+ /**
12
+ * Read only the dedicated variant payload generated by the plugin. OpenCode
13
+ * merges model, agent, and variant options into one providerOptions namespace;
14
+ * treating that whole object as Cursor parameters would leak unrelated options
15
+ * onto the wire.
16
+ */
17
+ export function extractCursorVariantParameters(providerOptions) {
18
+ const raw = providerOptions?.[CURSOR_VARIANT_PARAMETERS_KEY];
19
+ if (!Array.isArray(raw) || raw.length === 0)
20
+ return undefined;
21
+ const params = [];
22
+ for (const item of raw) {
23
+ if (!item || typeof item !== "object" || Array.isArray(item))
24
+ return undefined;
25
+ const { id, value } = item;
26
+ if (typeof id !== "string" || typeof value !== "string")
27
+ return undefined;
28
+ params.push({ id, value });
29
+ }
30
+ return params;
31
+ }
32
+ export function resolveCursorWireModelId(providerOptions, fallback) {
33
+ const value = providerOptions?.[CURSOR_WIRE_MODEL_ID_KEY];
34
+ return typeof value === "string" && value.trim() ? value : fallback;
35
+ }
36
+ /** True when resolved params select the long-context (1m) tier. */
37
+ export function paramsImplyMaxMode(params) {
38
+ return params.some((p) => p.id === "context" && p.value === "1m");
39
+ }
40
+ // Cursor encodes a model's context window as a variant parameter `id: "context"`
41
+ // whose value is a tier string — the same 200k / 272k / 300k / 1m the IDE's
42
+ // picker shows. The base tier rides on the default non-max variant; the 1M
43
+ // tier (when the model supports max mode) rides on the default max variant.
44
+ // `context_token_limit` (#15) / `context_token_limit_for_max_mode` (#16) are
45
+ // also defined on AvailableModel but the server often leaves them empty — the
46
+ // variant param is the primary source (request flags may still populate them).
47
+ const CONTEXT_TIER_TO_TOKENS = {
48
+ "200k": 200_000,
49
+ "272k": 272_000,
50
+ "300k": 300_000,
51
+ "1m": 1_000_000,
52
+ };
53
+ function variantContextTokens(v) {
54
+ const raw = v?.parameterValues.find((p) => p.id === "context")?.value;
55
+ if (!raw)
56
+ return undefined;
57
+ const mapped = CONTEXT_TIER_TO_TOKENS[raw];
58
+ if (mapped !== undefined)
59
+ return mapped;
60
+ const n = Number(raw);
61
+ return Number.isFinite(n) && n > 0 ? n : undefined;
62
+ }
7
63
  // ── Cache helpers ──
8
64
  export function isCacheFresh(cache, ttlMs = MODEL_CACHE_TTL_MS) {
65
+ if (cache.schemaVersion !== MODEL_CACHE_SCHEMA_VERSION)
66
+ return false;
9
67
  return Date.now() - cache.fetchedAt < ttlMs;
10
68
  }
11
69
  export function cacheFilePath(cacheDir) {
@@ -53,12 +111,22 @@ export function mapAvailableModelsResponse(raw) {
53
111
  isDefaultMax: !!(v.isDefaultMaxConfig ?? v.is_default_max_config ?? false),
54
112
  });
55
113
  }
114
+ // Derive the context window from the variant `context` param (primary
115
+ // source). Base tier ← default non-max variant; max tier ← default max
116
+ // variant. Fall back to the (often empty) proto fields, then undefined.
117
+ // Prefer a non-1m context when isDefaultNonMax is missing so a leading
118
+ // max-mode variant cannot inflate the base window.
119
+ const variantBaseContext = variantContextTokens(variants.find((v) => v.isDefaultNonMax)) ??
120
+ variantContextTokens(variants.find((v) => v.parameterValues.some((p) => p.id === "context" && p.value !== "1m"))) ??
121
+ variantContextTokens(variants.find((v) => v.parameterValues.some((p) => p.id === "context")));
122
+ const variantMaxContext = variantContextTokens(variants.find((v) => v.isDefaultMax));
56
123
  models.push({
57
124
  id: name,
58
125
  displayName: (e.clientDisplayName ?? e.client_display_name ?? name),
59
126
  supportsThinking: !!(e.supportsThinking ?? e.supports_thinking ?? false),
60
127
  supportsAgent: !!(e.supportsAgent ?? e.supports_agent ?? false),
61
- maxContext: (e.contextTokenLimit ?? e.context_token_limit ?? undefined),
128
+ maxContext: (variantBaseContext ?? e.contextTokenLimit ?? e.context_token_limit ?? undefined),
129
+ maxContextForMaxMode: (variantMaxContext ?? e.contextTokenLimitForMaxMode ?? e.context_token_limit_for_max_mode ?? undefined),
62
130
  supportsMaxMode: !!(e.supportsMaxMode ?? e.supports_max_mode ?? false),
63
131
  variants,
64
132
  });
@@ -68,23 +136,54 @@ export function mapAvailableModelsResponse(raw) {
68
136
  // ── Variant resolution ──
69
137
  /**
70
138
  * Resolve the parameter values to send in `requested_model.parameters` for a
71
- * given model + requested reasoning effort. Each variant already carries the
72
- * full `{id,value}` set (per-model vocabulary), so we pick the matching variant
73
- * and return its parameters verbatim the client never constructs these by
74
- * hand.
139
+ * given model + the variant opencode selected. Each variant already carries
140
+ * the full `{id,value}` set (per-model vocabulary effort, fast, thinking,
141
+ * context, …), so we pick the matching variant and return its parameters
142
+ * verbatim — the client never constructs these by hand.
143
+ *
144
+ * `picked` is the user-selected variant paramMap (opencode sends it under
145
+ * `providerOptions.cursor`); when present it wins over effort/maxMode hints
146
+ * so every param the user chose (context, fast, …) is forwarded to Cursor.
147
+ * Hints (`reasoningEffort`, `maxMode`) live beside the dedicated picked array,
148
+ * so the picked values are already isolated and can be matched verbatim.
75
149
  */
76
150
  export function resolveVariantParameters(model, opts = {}) {
151
+ const picked = opts.picked?.length ? opts.picked.map((p) => ({ ...p })) : undefined;
77
152
  if (!model || model.variants.length === 0) {
78
- // No variant metadata: fall back to a bare effort param if requested.
153
+ if (picked?.length)
154
+ return picked;
79
155
  return opts.reasoningEffort ? [{ id: "effort", value: opts.reasoningEffort }] : [];
80
156
  }
81
157
  const effortOf = (v) => v.parameterValues.find((p) => p.id === "effort" || p.id === "reasoning")?.value;
82
158
  const isFast = (v) => v.parameterValues.find((p) => p.id === "fast")?.value === "true";
159
+ const contextOf = (v) => v.parameterValues.find((p) => p.id === "context")?.value;
160
+ const isMaxVariant = (v) => v.isDefaultMax || contextOf(v) === "1m";
83
161
  const wantMax = opts.maxMode ?? false;
84
- const candidates = model.variants.filter((v) => !isFast(v));
85
- const pool = candidates.length > 0 ? candidates : model.variants;
162
+ // Non-fast pool for hint-based resolution only. Exact `picked` matching
163
+ // searches all variants so Fast selections are not silently dropped.
164
+ const pool = model.variants.filter((v) => !isFast(v));
165
+ const scoped = pool.length > 0 ? pool : model.variants;
166
+ // 1. Variant explicitly picked by opencode (verbatim — preserves context, fast, …).
167
+ if (picked?.length) {
168
+ const signature = new Set(picked.map((p) => `${p.id}=${p.value}`));
169
+ const exact = model.variants.find((v) => v.parameterValues.every((p) => signature.has(`${p.id}=${p.value}`)) &&
170
+ signature.size === v.parameterValues.length);
171
+ if (exact) {
172
+ return buildRequestedModelParams(exact.parameterValues, {
173
+ reasoningEffort: opts.reasoningEffort,
174
+ maxMode: wantMax,
175
+ });
176
+ }
177
+ // Unmatched: forward the stripped pick so the TUI choice is not discarded.
178
+ return picked;
179
+ }
180
+ // When maxMode is requested, prefer max-tier variants for effort matching
181
+ // so "high + max" lands on 1m high rather than the first 300k high.
182
+ const maxScoped = wantMax ? scoped.filter(isMaxVariant) : [];
183
+ const effortPool = maxScoped.length > 0 ? maxScoped : scoped;
184
+ // 2. Effort hint (e.g. user passed reasoningEffort through the CLI).
86
185
  if (opts.reasoningEffort) {
87
- const match = pool.find((v) => effortOf(v) === opts.reasoningEffort);
186
+ const match = effortPool.find((v) => effortOf(v) === opts.reasoningEffort);
88
187
  if (match) {
89
188
  return buildRequestedModelParams(match.parameterValues, {
90
189
  reasoningEffort: opts.reasoningEffort,
@@ -92,11 +191,17 @@ export function resolveVariantParameters(model, opts = {}) {
92
191
  });
93
192
  }
94
193
  }
95
- const byDefault = pool.find((v) => (wantMax ? v.isDefaultMax : v.isDefaultNonMax));
96
- const base = (byDefault ?? pool[0]).parameterValues;
97
- return buildRequestedModelParams(base, {
194
+ // 3. Max-mode hint prefer the default max variant (1m context).
195
+ if (wantMax) {
196
+ const max = scoped.find((v) => v.isDefaultMax) ?? scoped.find((v) => contextOf(v) === "1m");
197
+ if (max)
198
+ return buildRequestedModelParams(max.parameterValues, { maxMode: true });
199
+ }
200
+ // 4. Default non-max variant.
201
+ const byDefault = scoped.find((v) => v.isDefaultNonMax) ?? scoped[0];
202
+ return buildRequestedModelParams(byDefault.parameterValues, {
98
203
  reasoningEffort: opts.reasoningEffort,
99
- maxMode: wantMax,
204
+ maxMode: false,
100
205
  });
101
206
  }
102
207
  // ── Fetch + cache orchestration ──
@@ -110,7 +215,11 @@ export async function discoverModels(token, cacheDir, options = {}) {
110
215
  if (cached && isCacheFresh(cached)) {
111
216
  // Background refresh (fire and forget)
112
217
  fetchModels(token, options)
113
- .then((models) => writeCache(cacheDir, { models, fetchedAt: Date.now() }))
218
+ .then((models) => writeCache(cacheDir, {
219
+ models,
220
+ fetchedAt: Date.now(),
221
+ schemaVersion: MODEL_CACHE_SCHEMA_VERSION,
222
+ }))
114
223
  .catch(() => {
115
224
  /* background refresh failure is non-fatal */
116
225
  });
@@ -120,7 +229,11 @@ export async function discoverModels(token, cacheDir, options = {}) {
120
229
  if (cached) {
121
230
  try {
122
231
  const models = await fetchModels(token, options);
123
- const newCache = { models, fetchedAt: Date.now() };
232
+ const newCache = {
233
+ models,
234
+ fetchedAt: Date.now(),
235
+ schemaVersion: MODEL_CACHE_SCHEMA_VERSION,
236
+ };
124
237
  await writeCache(cacheDir, newCache);
125
238
  return models;
126
239
  }
@@ -130,7 +243,11 @@ export async function discoverModels(token, cacheDir, options = {}) {
130
243
  }
131
244
  // No cache → must fetch
132
245
  const models = await fetchModels(token, options);
133
- const newCache = { models, fetchedAt: Date.now() };
246
+ const newCache = {
247
+ models,
248
+ fetchedAt: Date.now(),
249
+ schemaVersion: MODEL_CACHE_SCHEMA_VERSION,
250
+ };
134
251
  await writeCache(cacheDir, newCache);
135
252
  return models;
136
253
  }
package/dist/plugin.d.ts CHANGED
@@ -11,5 +11,7 @@ import { type ModelInfo } from "./models.js";
11
11
  export declare function thinkingSuffixBaseNames(models: ModelInfo[]): Set<string>;
12
12
  export declare function modelInfoToConfig(mi: ModelInfo, options?: {
13
13
  thinkingSuffix?: boolean;
14
+ contextTier?: "base" | "long";
14
15
  }): Record<string, any>;
16
+ export declare function modelsToConfig(models: ModelInfo[]): Record<string, any>;
15
17
  export declare function CursorPlugin(input: PluginInput): Promise<Hooks>;
package/dist/plugin.js CHANGED
@@ -1,18 +1,25 @@
1
1
  import { CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, CURSOR_API_HOST } from "./shared.js";
2
2
  import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtPayload } from "./auth.js";
3
- import { readCache, discoverModels, isCacheFresh } from "./models.js";
3
+ import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, readCache, discoverModels, isCacheFresh } from "./models.js";
4
4
  import { opencodeGlobalCacheDir } from "./context/paths.js";
5
5
  import { readStoredAuth } from "./context/auth-store.js";
6
6
  import { resolveAgentUrl } from "./agent-url.js";
7
7
  const MODULE_URL = new URL("./index.js", import.meta.url).href;
8
8
  /**
9
- * Strip characters that break rendering in the OpenCode Desktop webview from a
10
- * model or variant display name: HTML/markup chars (`< > & " ' \``), parentheses,
11
- * Tabs/newlines collapse to single spaces; dots and unicode letters are preserved so names
12
- * stay readable. Fixes https://github.com/oakimov/cursor-opencode-provider/issues/2.
9
+ * Strip characters and markup that break rendering in the OpenCode TUI/GUI from
10
+ * a model or variant display name:
11
+ * HTML tags (e.g. `<span style="…">Medium</span>`) the IDE colours variant
12
+ * suffixes with a CSS var that doesn't exist in OpenCode, so the raw markup
13
+ * would show as literal text. We drop the tags and keep the inner text
14
+ * ("Medium").
15
+ * • HTML/markup chars (`< > & " ' \``), parentheses.
16
+ * • Tabs/newlines collapse to single spaces.
17
+ * Dots and unicode letters are preserved so names stay readable.
18
+ * Fixes https://github.com/oakimov/cursor-opencode-provider/issues/2.
13
19
  */
14
20
  function safeLabel(value) {
15
21
  return (value
22
+ .replace(/<[^>]+>/g, "")
16
23
  .replace(/[()<>&"'`]/g, "")
17
24
  .replace(/\s+/g, " ")
18
25
  .trim() || "default");
@@ -20,26 +27,69 @@ function safeLabel(value) {
20
27
  function baseName(mi) {
21
28
  return safeLabel(mi.displayName ?? mi.id);
22
29
  }
23
- function modelInfoVariants(mi) {
24
- if (mi.variants.length === 0)
30
+ function modelInfoVariants(mi, variants) {
31
+ if (variants.length === 0)
25
32
  return undefined;
26
33
  const entries = {};
27
34
  const usedKeys = new Set();
28
- for (const v of mi.variants) {
29
- const base = safeLabel(v.displayName || v.key || "default");
30
- let key = base;
31
- let suffix = 2;
35
+ const baseName = safeLabel(mi.displayName ?? mi.id);
36
+ // Each variant's key is `safeLabel(displayName)` (the IDE's own label, e.g.
37
+ // "Opus 4.8 1M High Fast Thinking") so the picker matches what the user
38
+ // sees in Cursor. Two variants can sanitize to the same name when the IDE
39
+ // wraps a differentiator in `<span>…</span>` (e.g. Composer's "Fast"
40
+ // suffix collapses to the bare model name after stripping). To guarantee
41
+ // every variant stays pickable:
42
+ // 1. If the sanitized displayName matches the model name itself, suffix
43
+ // it with distinguishing params (or "default") so it never collides
44
+ // with the model entry in the variant panel.
45
+ // 2. If two variants still collide, tag the later one with its params.
46
+ // Suffixes must stay free of `()` / markup chars — same constraint as
47
+ // safeLabel (issue #2); use spaced tokens, not parenthetical tags.
48
+ const tagDims = (p) => {
49
+ const labels = [];
50
+ for (const d of p) {
51
+ if (d.id === "fast" && d.value === "true")
52
+ labels.push("Fast");
53
+ else if (d.id === "thinking" && d.value === "true")
54
+ labels.push("Thinking");
55
+ else if (d.id === "context")
56
+ labels.push(d.value);
57
+ }
58
+ if (labels.length > 0)
59
+ return ` ${labels.join(" ")}`;
60
+ // No params at all — still disambiguate from the model name itself.
61
+ if (p.length === 0)
62
+ return "";
63
+ return " default";
64
+ };
65
+ for (const v of variants) {
66
+ const sanitized = safeLabel(v.displayName || v.key || "default");
67
+ let key = sanitized;
68
+ // Never let a variant key equal the model name — that would make the
69
+ // variant entry indistinguishable from the model entry in pickers that
70
+ // collapse them.
71
+ if (key === baseName && !usedKeys.has(key)) {
72
+ key = `${baseName}${tagDims(v.parameterValues)}` || `${baseName} default`;
73
+ }
74
+ else if (usedKeys.has(key)) {
75
+ key = `${sanitized}${tagDims(v.parameterValues)}`;
76
+ }
77
+ let n = 2;
32
78
  while (usedKeys.has(key))
33
- key = `${base}--${suffix++}`;
79
+ key = `${sanitized}${tagDims(v.parameterValues)} ${n++}`;
34
80
  usedKeys.add(key);
35
- const params = {};
36
- for (const p of v.parameterValues) {
37
- params[p.id] = p.value;
38
- }
39
- entries[key] = params;
81
+ entries[key] = {
82
+ [CURSOR_VARIANT_PARAMETERS_KEY]: v.parameterValues.map((p) => ({ ...p })),
83
+ };
40
84
  }
41
85
  return entries;
42
86
  }
87
+ function isLongContextVariant(v) {
88
+ return v.parameterValues.some((p) => p.id === "context" && p.value === "1m");
89
+ }
90
+ function variantsForTier(mi, tier) {
91
+ return mi.variants.filter((v) => isLongContextVariant(v) === (tier === "long"));
92
+ }
43
93
  /**
44
94
  * Display names shared by both a thinking and a non-thinking model. A thinking
45
95
  * model with such a name needs a "Thinking" tag to disambiguate it from its
@@ -66,31 +116,66 @@ export function thinkingSuffixBaseNames(models) {
66
116
  return ambiguous;
67
117
  }
68
118
  export function modelInfoToConfig(mi, options = {}) {
119
+ const contextTier = options.contextTier ?? "base";
120
+ const variants = variantsForTier(mi, contextTier);
69
121
  let name = baseName(mi);
70
122
  if (options.thinkingSuffix)
71
123
  name += " Thinking";
124
+ if (contextTier === "long")
125
+ name += " 1M";
126
+ // OpenCode's context limit is static per model entry, while Cursor's context
127
+ // tier is a variant parameter. Long-context choices are therefore emitted as
128
+ // separate OpenCode entries by modelsToConfig.
129
+ const context = contextTier === "long"
130
+ ? (mi.maxContextForMaxMode ?? 1_000_000)
131
+ : (mi.maxContext ?? 200_000);
72
132
  const config = {
73
133
  name,
74
134
  reasoning: mi.supportsThinking ?? false,
75
135
  tool_call: mi.supportsAgent ?? true,
76
136
  temperature: false,
77
137
  limit: {
78
- context: mi.maxContext ?? 200000,
138
+ context,
79
139
  output: 4096,
80
140
  },
81
141
  };
82
- const variants = modelInfoVariants(mi);
83
- if (variants)
84
- config.variants = variants;
142
+ const variantConfig = modelInfoVariants(mi, variants);
143
+ if (variantConfig)
144
+ config.variants = variantConfig;
145
+ if (contextTier === "long") {
146
+ const defaultVariant = variants.find((v) => v.isDefaultMax) ?? variants[0];
147
+ if (defaultVariant) {
148
+ config.options = {
149
+ [CURSOR_WIRE_MODEL_ID_KEY]: mi.id,
150
+ [CURSOR_VARIANT_PARAMETERS_KEY]: defaultVariant.parameterValues.map((p) => ({ ...p })),
151
+ };
152
+ }
153
+ }
85
154
  return config;
86
155
  }
87
- function modelsToConfig(models) {
156
+ export function modelsToConfig(models) {
88
157
  const ambiguous = thinkingSuffixBaseNames(models);
89
158
  const out = {};
159
+ const usedIds = new Set(models.map((m) => m.id));
90
160
  for (const m of models) {
91
- out[m.id] = modelInfoToConfig(m, {
92
- thinkingSuffix: !!m.supportsThinking && ambiguous.has(baseName(m)),
93
- });
161
+ const thinkingSuffix = !!m.supportsThinking && ambiguous.has(baseName(m));
162
+ const baseVariants = variantsForTier(m, "base");
163
+ const longVariants = variantsForTier(m, "long");
164
+ if (baseVariants.length > 0 || longVariants.length === 0) {
165
+ out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "base" });
166
+ }
167
+ if (longVariants.length === 0)
168
+ continue;
169
+ if (baseVariants.length === 0) {
170
+ out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
171
+ continue;
172
+ }
173
+ let longId = `${m.id}-1m`;
174
+ let suffix = 2;
175
+ while (usedIds.has(longId))
176
+ longId = `${m.id}-1m-${suffix++}`;
177
+ usedIds.add(longId);
178
+ out[longId] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
94
179
  }
95
180
  return out;
96
181
  }
@@ -198,25 +283,27 @@ export async function CursorPlugin(input) {
198
283
  }
199
284
  async function loadModels() {
200
285
  const cached = await readCache(cacheDir);
201
- if (!cached || cached.models.length === 0) {
202
- // Config runs before auth.loader and has no getAuth(); read the durable
203
- // store (normally the same source getAuth() uses).
204
- const auth = await authFromStore();
205
- if (auth) {
206
- const accessToken = await resolveAccessToken(auth);
207
- if (accessToken) {
208
- try {
209
- const models = await discoverModels(accessToken, cacheDir, { baseURL: apiBaseURL });
210
- return modelsToConfig(models);
211
- }
212
- catch {
213
- // discovery failed — leave the list empty
214
- }
286
+ if (cached?.models.length && isCacheFresh(cached)) {
287
+ return modelsToConfig(cached.models);
288
+ }
289
+ // Config runs before auth.loader and has no getAuth(); read the durable
290
+ // store (normally the same source getAuth() uses). Refresh missing, expired,
291
+ // or old-schema caches here so this process materializes the new model set.
292
+ const auth = await authFromStore();
293
+ if (auth) {
294
+ const accessToken = await resolveAccessToken(auth);
295
+ if (accessToken) {
296
+ try {
297
+ const models = await discoverModels(accessToken, cacheDir, { baseURL: apiBaseURL });
298
+ return modelsToConfig(models);
299
+ }
300
+ catch {
301
+ // No usable cache and discovery failed — leave the list empty.
215
302
  }
216
303
  }
217
- return {};
218
304
  }
219
- return modelsToConfig(cached.models);
305
+ // Preserve stale-on-failure/offline behavior for an existing cache.
306
+ return cached?.models.length ? modelsToConfig(cached.models) : {};
220
307
  }
221
308
  return {
222
309
  async config(cfg) {
@@ -53,6 +53,12 @@ export type ToolResultInput = {
53
53
  output: string;
54
54
  error?: string;
55
55
  executionTimeMs?: number;
56
+ /**
57
+ * Resolved opencode tool name (read/write/grep/…). Gates read-envelope
58
+ * unwrapping on the mcp_result path so non-read MCP output is never
59
+ * rewritten. read_result is always a read, so it unwraps regardless.
60
+ */
61
+ toolName?: string;
56
62
  };
57
63
  /**
58
64
  * Build one or more ExecClientMessage frames for a tool result.
@@ -64,12 +70,33 @@ export type ToolResultInput = {
64
70
  export declare function buildExecClientMessages(input: ToolResultInput): Uint8Array[];
65
71
  /** ACM #5 exec_client_control_message { stream_close { id } }. */
66
72
  export declare function buildExecStreamClose(execId: number): Uint8Array;
73
+ /**
74
+ * Strip opencode's `read` envelope, leaving raw file content.
75
+ *
76
+ * opencode's read tool (opencode `tool/read.ts`) wraps content in an XML-ish
77
+ * envelope its own models are trained on, but Cursor's are not:
78
+ * <path>{abs}</path>\n<type>file</type>\n<content>\n{N}: {line}\n…\n\n{footer}\n</content>
79
+ * Forwarding that envelope verbatim made Cursor's model treat the wrapper as
80
+ * literal file content and write `<path>`/`<content>` tags + `N:` line prefixes
81
+ * back into files (silent corruption — the write still reports success; seen
82
+ * across 8+ sessions, e.g. language-model.ts rewritten starting with
83
+ * `<path>…</path>\n<type>file</type>\n<content>\n1: import fs…`).
84
+ *
85
+ * Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
86
+ *
87
+ * Deliberately exception-safe: if the expected envelope is absent — non-read
88
+ * output, already-raw text, or a future opencode format change — it returns the
89
+ * input unchanged, so a result is never broken and we never throw mid-turn.
90
+ * Callers must still gate `mcp_result` on `toolName === "read"`; this helper
91
+ * alone is not a tool-identity check.
92
+ */
93
+ export declare function unwrapReadOutput(output: string): string;
67
94
  /**
68
95
  * Map OpenCode tool text into the agent.v1 result oneof for each exec variant.
69
96
  * OpenCode returns free-form text; we wrap it in the minimal success shape the
70
97
  * server accepts (verified against agent.v1 wire captures).
71
98
  */
72
- export declare function buildTypedExecResult(resultField: string, output: string, error?: string): Record<string, unknown>;
99
+ export declare function buildTypedExecResult(resultField: string, output: string, error?: string, toolName?: string): Record<string, unknown>;
73
100
  export declare function buildToolCallPart(execMsg: ParsedExecRequest, sessionId: string): {
74
101
  toolCallId: string;
75
102
  toolName: string;
@@ -1,5 +1,6 @@
1
1
  import { encodeMessage } from "./messages.js";
2
2
  import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
3
+ import { trace } from "../debug.js";
3
4
  // Exec variant field number whose reply is the server-initiated request_context
4
5
  // probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
5
6
  // field number for every exec variant, so this is also the result field.
@@ -394,7 +395,7 @@ export function buildExecClientMessages(input) {
394
395
  id: input.execId,
395
396
  local_execution_time_ms: input.executionTimeMs ?? 0,
396
397
  };
397
- clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error);
398
+ clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName);
398
399
  frames.push(encodeMessage("AgentClientMessage", {
399
400
  exec_client_message: clientMsg,
400
401
  }));
@@ -411,21 +412,90 @@ export function buildExecStreamClose(execId) {
411
412
  },
412
413
  });
413
414
  }
415
+ /**
416
+ * Strip opencode's `read` envelope, leaving raw file content.
417
+ *
418
+ * opencode's read tool (opencode `tool/read.ts`) wraps content in an XML-ish
419
+ * envelope its own models are trained on, but Cursor's are not:
420
+ * <path>{abs}</path>\n<type>file</type>\n<content>\n{N}: {line}\n…\n\n{footer}\n</content>
421
+ * Forwarding that envelope verbatim made Cursor's model treat the wrapper as
422
+ * literal file content and write `<path>`/`<content>` tags + `N:` line prefixes
423
+ * back into files (silent corruption — the write still reports success; seen
424
+ * across 8+ sessions, e.g. language-model.ts rewritten starting with
425
+ * `<path>…</path>\n<type>file</type>\n<content>\n1: import fs…`).
426
+ *
427
+ * Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
428
+ *
429
+ * Deliberately exception-safe: if the expected envelope is absent — non-read
430
+ * output, already-raw text, or a future opencode format change — it returns the
431
+ * input unchanged, so a result is never broken and we never throw mid-turn.
432
+ * Callers must still gate `mcp_result` on `toolName === "read"`; this helper
433
+ * alone is not a tool-identity check.
434
+ */
435
+ export function unwrapReadOutput(output) {
436
+ if (typeof output !== "string" || output.length === 0)
437
+ return output;
438
+ // Require the full opencode read-envelope skeleton *before* `<content>`
439
+ // (read.ts opens with <path>…</path>, <type>file</type>, <content>) so a
440
+ // stray "<content>" later in tool chatter can't trigger unwrapping just
441
+ // because path/type tags appear elsewhere in the payload.
442
+ const contentHeaderIdx = output.indexOf("<content>");
443
+ if (contentHeaderIdx === -1)
444
+ return output;
445
+ const header = output.slice(0, contentHeaderIdx);
446
+ const hasSkeleton = header.indexOf("<path>") !== -1 &&
447
+ header.indexOf("<type>file</type>") !== -1;
448
+ if (!hasSkeleton) {
449
+ // Saw "<content>" but not the read skeleton ahead of it — almost certainly
450
+ // a non-read payload, or an opencode format drift. Surface it so drift
451
+ // can't silently resurrect the wrapper-corruption bug, but still fail
452
+ // safe (no mutate).
453
+ trace("unwrapReadOutput: <content> present without leading <path>/<type>file> skeleton — leaving output unchanged (possible non-read payload or opencode read format drift)");
454
+ return output;
455
+ }
456
+ // Body starts right after "<content>\n". opencode emits one numbered line per
457
+ // file line ("N: <line>"), then a blank, a "(…)" footer, and a standalone
458
+ // "</content>". The body is a *contiguous run* of /^N: / lines — so we stop at
459
+ // the first non-numbered line. Critically we do NOT search for a closing
460
+ // "</content>" substring: a file line that literally contains "</content>"
461
+ // is rendered as "N: </content>" (a body line), and a raw indexOf would
462
+ // truncate the read there.
463
+ let rest = output.slice(contentHeaderIdx + "<content>".length);
464
+ if (rest.startsWith("\n"))
465
+ rest = rest.slice(1);
466
+ const raw = [];
467
+ for (const line of rest.split("\n")) {
468
+ const m = /^(\d+):[ \t]?(.*)$/.exec(line);
469
+ if (!m)
470
+ break; // blank / "(footer)" / "</content>" → end of body run
471
+ // Strip only the leading "N: " prefix; a line that itself begins with
472
+ // digits+colon keeps its content (we remove just the first match). Blank
473
+ // file lines render as "N: " and are preserved (capture group is "").
474
+ raw.push(m[2]);
475
+ }
476
+ // Envelope confirmed but no numbered body → empty file. Return "" rather
477
+ // than the envelope (the envelope is exactly what Cursor echoes into writes).
478
+ return raw.join("\n");
479
+ }
414
480
  /**
415
481
  * Map OpenCode tool text into the agent.v1 result oneof for each exec variant.
416
482
  * OpenCode returns free-form text; we wrap it in the minimal success shape the
417
483
  * server accepts (verified against agent.v1 wire captures).
418
484
  */
419
- export function buildTypedExecResult(resultField, output, error) {
485
+ export function buildTypedExecResult(resultField, output, error, toolName) {
420
486
  switch (resultField) {
421
487
  case "read_result":
422
488
  if (error)
423
489
  return { error: { path: "", error } };
490
+ // Strip opencode's <path>/<content> envelope so Cursor's model receives
491
+ // raw file content and can't echo the wrapper into subsequent writes.
492
+ // reads route here only for native read_args; most arrive via mcp_result.
493
+ const content = unwrapReadOutput(output);
424
494
  return {
425
495
  success: {
426
496
  path: extractPathTag(output) ?? "",
427
- content: output,
428
- total_lines: countLines(output),
497
+ content,
498
+ total_lines: countLines(content),
429
499
  },
430
500
  };
431
501
  case "grep_result": {
@@ -492,9 +562,15 @@ export function buildTypedExecResult(resultField, output, error) {
492
562
  case "mcp_result":
493
563
  if (error)
494
564
  return { error: { error } };
565
+ // opencode built-ins (read/write/grep/…) are advertised as MCP tools, so
566
+ // a read call returns through mcp_result. Scope the unwrap to toolName
567
+ // "read" so a non-read MCP tool whose output merely contains a
568
+ // "<content>"-like block is never rewritten.
495
569
  return {
496
570
  success: {
497
- content: [{ text: { text: output } }],
571
+ content: [
572
+ { text: { text: toolName === "read" ? unwrapReadOutput(output) : output } },
573
+ ],
498
574
  is_error: false,
499
575
  },
500
576
  };
package/dist/session.d.ts CHANGED
@@ -14,6 +14,11 @@ export type Frame = {
14
14
  export type PendingExec = {
15
15
  /** ExecClientMessage result field to reply with (matches the request variant). */
16
16
  resultField: string;
17
+ /**
18
+ * Resolved opencode tool name (read/write/grep/…). Used on continuation so
19
+ * mcp_result can unwrap read envelopes even if the prompt omits toolName.
20
+ */
21
+ toolName?: string;
17
22
  };
18
23
  export type CursorSession = {
19
24
  /**
@@ -58,7 +63,7 @@ export declare class SessionManager {
58
63
  constructor(idleTimeoutMs?: number);
59
64
  touch(session: CursorSession): void;
60
65
  /** Register that `session` is awaiting a result for `execId`. */
61
- registerPending(execId: number, session: CursorSession, resultField: string): void;
66
+ registerPending(execId: number, session: CursorSession, resultField: string, toolName?: string): void;
62
67
  /** The pending exec info for an id on a specific session, if still awaiting it. */
63
68
  pendingFor(sessionId: string, execId: number): PendingExec | undefined;
64
69
  /** Find the live session awaiting one of the given exec ids. */
package/dist/session.js CHANGED
@@ -1,4 +1,4 @@
1
- import { trace } from "./transport/connect.js";
1
+ import { trace } from "./debug.js";
2
2
  export class SessionManager {
3
3
  // Composite key `${sessionId}:${execId}` → owning session. Composite keying
4
4
  // means two Run streams that both register an execId of 1 (Cursor resets
@@ -12,8 +12,8 @@ export class SessionManager {
12
12
  session.expiresAt = Date.now() + this.idleTimeoutMs;
13
13
  }
14
14
  /** Register that `session` is awaiting a result for `execId`. */
15
- registerPending(execId, session, resultField) {
16
- session.pending.set(execId, { resultField });
15
+ registerPending(execId, session, resultField, toolName) {
16
+ session.pending.set(execId, { resultField, toolName });
17
17
  this.byExecId.set(this.key(session.sessionId, execId), session);
18
18
  this.touch(session);
19
19
  }
package/dist/shared.d.ts CHANGED
@@ -7,6 +7,8 @@ export declare const RUN_PATH = "/agent.v1.AgentService/Run";
7
7
  export declare const AVAILABLE_MODELS_PATH = "/aiserver.v1.AiService/AvailableModels";
8
8
  export declare const SERVER_CONFIG_PATH = "/aiserver.v1.ServerConfigService/GetServerConfig";
9
9
  export declare const MODEL_CACHE_FILE = "cursor-models.json";
10
+ /** Bumped when the on-disk model cache shape/semantics change (forces refetch). */
11
+ export declare const MODEL_CACHE_SCHEMA_VERSION = 2;
10
12
  export declare const MODEL_CACHE_TTL_MS = 86400000;
11
13
  export declare const VERSION_CACHE_FILE = "cursor-client-version.json";
12
14
  export declare const CONTENT_TYPE_CONNECT_PROTO = "application/connect+proto";
package/dist/shared.js CHANGED
@@ -7,6 +7,8 @@ export const RUN_PATH = "/agent.v1.AgentService/Run";
7
7
  export const AVAILABLE_MODELS_PATH = "/aiserver.v1.AiService/AvailableModels";
8
8
  export const SERVER_CONFIG_PATH = "/aiserver.v1.ServerConfigService/GetServerConfig";
9
9
  export const MODEL_CACHE_FILE = "cursor-models.json";
10
+ /** Bumped when the on-disk model cache shape/semantics change (forces refetch). */
11
+ export const MODEL_CACHE_SCHEMA_VERSION = 2;
10
12
  export const MODEL_CACHE_TTL_MS = 86_400_000;
11
13
  export const VERSION_CACHE_FILE = "cursor-client-version.json";
12
14
  export const CONTENT_TYPE_CONNECT_PROTO = "application/connect+proto";
@@ -1,4 +1,3 @@
1
- export declare function trace(msg: string): void;
2
1
  export declare function buildBaseHeaders(token: string, clientVersion: string, extra?: Record<string, string>): Record<string, string>;
3
2
  export declare function unaryAvailableModels(token: string, options?: {
4
3
  apiBaseURL?: string;
@@ -3,32 +3,12 @@ import { encodeFrame, streamFrames } from "../protocol/framing.js";
3
3
  import { createCursorChecksumHeader } from "../protocol/checksum.js";
4
4
  import { getDeviceIds } from "../protocol/device-id.js";
5
5
  import { resolveClientVersion } from "../protocol/client-version.js";
6
+ import { trace } from "../debug.js";
6
7
  import http2 from "node:http2";
7
- import fs from "node:fs";
8
8
  const API_BASE = `https://${CURSOR_API_HOST}`;
9
9
  function resolveApiBaseURL(options) {
10
10
  return new URL(options.apiBaseURL ?? options.baseURL ?? API_BASE).origin;
11
11
  }
12
- // Wire-level diagnostics. Opt in with CURSOR_PROVIDER_DEBUG=1 (or "true").
13
- // Writes to CURSOR_PROVIDER_DEBUG_FILE (default /tmp/cursor-provider-debug.log).
14
- // Truncated once per process. Captures h2 response status, parsed frames, and
15
- // stream errors. Tokens / checksums are redacted in header dumps.
16
- const DEBUG_ENABLED = process.env.CURSOR_PROVIDER_DEBUG === "1" ||
17
- process.env.CURSOR_PROVIDER_DEBUG === "true";
18
- const DEBUG_FILE = process.env.CURSOR_PROVIDER_DEBUG_FILE || "/tmp/cursor-provider-debug.log";
19
- let _traceInitialized = false;
20
- export function trace(msg) {
21
- if (!DEBUG_ENABLED)
22
- return;
23
- try {
24
- if (!_traceInitialized) {
25
- _traceInitialized = true;
26
- fs.writeFileSync(DEBUG_FILE, `--- cursor-provider debug (pid ${process.pid}) ${new Date().toISOString()} ---\n`);
27
- }
28
- fs.appendFileSync(DEBUG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
29
- }
30
- catch { /* ignore */ }
31
- }
32
12
  trace("connect.ts module loaded");
33
13
  export function buildBaseHeaders(token, clientVersion, extra) {
34
14
  const { machineId, macMachineId } = getDeviceIds();
@@ -56,7 +36,17 @@ export async function unaryAvailableModels(token, options = {}) {
56
36
  "content-type": "application/json",
57
37
  accept: "application/json",
58
38
  },
59
- body: "{}",
39
+ // AvailableModelsRequest flags (proto aiserver.v1). The IDE sets these
40
+ // (modelConfigService.js useModelParameters, entry.js includeLongContextModels).
41
+ // useModelParameters + useCloudAgentEffortModes return parameterized
42
+ // variants (effort/context/fast). includeLongContextModels may populate
43
+ // context_token_limit fields; when those stay empty we still derive limits
44
+ // from each variant's `context` param in mapAvailableModelsResponse.
45
+ body: JSON.stringify({
46
+ includeLongContextModels: true,
47
+ useModelParameters: true,
48
+ useCloudAgentEffortModes: true,
49
+ }),
60
50
  });
61
51
  if (!res.ok) {
62
52
  const text = await res.text().catch(() => "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,10 +54,10 @@
54
54
  "protobufjs": "^7.4.0"
55
55
  },
56
56
  "peerDependencies": {
57
- "@opencode-ai/plugin": "~1.17.13"
57
+ "@opencode-ai/plugin": "^1.17.13"
58
58
  },
59
59
  "devDependencies": {
60
- "@opencode-ai/plugin": "~1.17.13",
60
+ "@opencode-ai/plugin": "^1.17.13",
61
61
  "@tsconfig/node22": "^22.0.1",
62
62
  "@types/node": "^22.15.3",
63
63
  "typescript": "^5.8.3"