cursor-opencode-provider 0.6.6 → 0.6.8

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
@@ -419,7 +419,9 @@ OpenCode
419
419
 
420
420
  ### Injected system guidance
421
421
 
422
- The provider adds OpenCode-specific system guidance to normal tool-capable conversations, including tool availability, canonical workspace-path grounding, and preferring `edit` / `write` (or `apply_patch`, when the host advertises that instead) over shell-based file mutation when those tools are available. Compaction keeps its dedicated prompt unchanged.
422
+ The provider adds OpenCode-specific system guidance to normal tool-capable conversations, including tool availability, canonical workspace-path grounding, preferring OpenCode `todowrite` / `todoread` over Cursor TodoWrite without inviting catalog-comparison narration, and preferring `edit` / `write` (or `apply_patch`, when the host advertises that instead) over shell-based file mutation when those tools are available. Compaction keeps its dedicated prompt unchanged.
423
+
424
+ Native Cursor todo display updates (`update_todos_tool_call` / `read_todos_tool_call`) are mirrored into `todowrite` / `todoread` when those host tools are advertised. The provider retains the last known full list per OpenCode session — refreshed by bridged writes, direct host `todowrite` calls, and `todoread` results, surviving Run close, checkpoint resume, and later turns — so `merge: true` updates that omit Cursor's completed list still become a host replace-all snapshot (matched by id, then by content across id spaces). This snapshot is in-memory only and never enters the prompt, RequestContext, or persisted restart state, so Cursor's prompt-cache prefix is unaffected.
423
425
 
424
426
  If this guidance causes issues, update `buildOpenCodeInteractionGuidance` in [`src/language-model.ts`](src/language-model.ts) and its focused coverage in [`test/prompt-history.test.ts`](test/prompt-history.test.ts).
425
427
 
@@ -492,7 +494,7 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
492
494
  - **Cursor's checkpoint controls displayed totals** — live continuation captures show `TurnEnded` counters can decrease between turns and can represent aggregate model work rather than final context occupancy. When a checkpoint contains `ConversationStateStructure.token_details`, the provider makes AI SDK input + output equal Cursor's `used_tokens`; at `TurnEnded`, output/reasoning remain based on `TurnEnded`, while uncached/cache-read/cache-write input are proportionally normalized to the remaining context input. OpenCode TUI/GUI replace each assistant message's tokens (TUI requires `tokens.output > 0`) and add cost per step, so mid-turn tool boundaries emit the last-known occupancy snapshot rather than a delta or a billed zero. Those snapshots set `output=1` so the footer updates and `providerMetadata.copilot.totalNanoAiu: 0` so OpenCode's getUsage reports $0 instead of charging occupancy as a new prompt. A Run without fresh token details retains the previous checkpoint snapshot and marks `providerMetadata.cursor.context` as `source: "checkpoint-previous-turn", stale: true`. If no checkpoint has ever supplied token details, standard usage is zero and context metadata is absent, so `TurnEnded` is never misrepresented as occupancy; its exact values remain under `providerMetadata.cursor.*Raw`. The full known snapshot includes `usedTokens`, `maxTokens`, percentage, category breakdown, source, and staleness. Cursor consistently sends `cache_write=0` in captured Runs, so no alternate write count is available.
493
495
  - **`request_context` from OpenCode** — each Run sends Cursor `RequestContext` built from OpenCode project context (workspace env, `AGENTS.md` / `instructions`, `.opencode` agents/skills/plugins, git, layout, plus `.claude`/`.agents` skill fallbacks). Expensive workspace state (rules, environment, repository, git, and layout) is frozen per Cursor conversation and persisted for restart recovery. Tools/MCP descriptors, skills, subagents, and plugin metadata are rediscovered and overlaid each Run; unchanged overlays reuse byte-identical encoded context, while enabling/disabling a capability becomes visible on the next turn. Concurrent first Runs share one base build, and compaction/reset clears it. Its canonical root is also used by the [injected system guidance](#injected-system-guidance). Same discovery as OpenCode — including `.cursor/` paths only when listed in `instructions`. Cursor-only cloud/sandbox marketplace surfaces are omitted. `env.workspace_paths` / `process_working_directory` stay on the real git workspace; Cursor's metadata root (`project_folder`, MCP `workspace_project_dir`, terminals/transcripts) is advertised under `<host-cache>/projects/<slug>/` (default `~/.cache/opencode/projects/…`) so dumps like `agent-tools/` do not land in the repo. OpenCode remains the permission authority: its coarse allow/ask/deny configuration is not fabricated into Cursor's unrelated allow/block instruction-list messages.
494
496
  - **Configured MCP tools keep their upstream server id** — OpenCode builtins and plugin/custom tools are advertised under a synthetic `opencode` MCP server. Tools whose flattened name matches an MCP server in merged `opencode.json` configuration (`github_create_pull_request`, …) are grouped into that server's `mcp_descriptors` / `provider_identifier` (`github`, …). Unknown underscore-containing names stay under `opencode` rather than being guessed incorrectly. Cursor's MCP-state exec probe is answered from the same advertised descriptors before the actual tool request, using the full canonical tool-definition identity required by native `get_mcp_tools`; exec still reconstructs the full OpenCode tool id.
495
- - **Display completions are notifications, not execution requests** — Cursor `tool_call_*` frames use a typed `ToolCall` oneof. The provider decodes them for diagnostics but only mirrors finalized todo/plan state (`update_todos_tool_call` / `create_plan_tool_call`) into advertised OpenCode `todowrite`; the completed payload already contains the authoritative final list. Interactive, data-returning, and side-effecting completions are never replayed as new tools because their result could not be returned to Cursor. Exec-backed native subagent/Task and Pi read/bash/edit/write/grep/find/ls calls use their typed request/result fields instead. Unknown display variants are logged. All 37 Cursor CLI exec request/result pairs are inventoried by field and name. Known-but-unsupported variants are soft-denied on the held Run with a populated typed error/`rejected`/`allowlisted:false` result (or `throw` when the CLI has no typed error shape) so the turn continues; unknown future request fields still fail the Run explicitly rather than receiving a guessed response that could deadlock it.
497
+ - **Display completions are notifications, not execution requests** — Cursor `tool_call_*` frames use a typed `ToolCall` oneof. The provider decodes them for diagnostics but only mirrors finalized todo/plan state (`update_todos_tool_call` → `todowrite`, `read_todos_tool_call` → `todoread`, `create_plan_tool_call` → `todowrite`); the completed payload already contains the authoritative final list. Interactive, data-returning, and side-effecting completions are never replayed as new tools because their result could not be returned to Cursor. Exec-backed native subagent/Task and Pi read/bash/edit/write/grep/find/ls calls use their typed request/result fields instead. Unknown display variants are logged. All 37 Cursor CLI exec request/result pairs are inventoried by field and name. Known-but-unsupported variants are soft-denied on the held Run with a populated typed error/`rejected`/`allowlisted:false` result (or `throw` when the CLI has no typed error shape) so the turn continues; unknown future request fields still fail the Run explicitly rather than receiving a guessed response that could deadlock it.
496
498
  - **Progress-only fragments can get one continuation** — if a real tool-enabled turn ends with a short progress fragment ("Checking the workspace", "Looking into it now") and no host tool call, the provider opens one follow-up Run with a synthetic user nudge so the model can call a listed tool or finish the answer. Complete answers that merely start with those verbs are not continued. Title/compaction turns (`allowTools=false`) never continue. If the continuation fails, the original `turn_ended` is still reported as a successful stop.
497
499
  - **Tool availability is per OpenCode agent** — Cursor can request native capabilities such as Task even when a child or restricted OpenCode agent did not advertise the corresponding host tool. The provider prompts Cursor with the exact current catalog and checks every decoded host-tool exec request against it. An unavailable request is answered on Cursor's correlated typed result channel and is never emitted as OpenCode's `invalid` tool.
498
500
  - **Host web tools use collision-safe aliases** — Cursor sees `custom_websearch` / `custom_webfetch`, and the held Run maps each alias back to an executable OpenCode tool with its schema, permission check, and correlated result intact. The plugin registers its search fallback directly as `custom_websearch`, avoiding OpenCode's reserved `websearch` id filter for third-party providers and taking precedence over MCP search providers such as Brave without host environment configuration. Exact host `webfetch` is translated the same way. Cursor's UI-bound native web interactions stay disabled because their approval replies cannot carry OpenCode tool results.
@@ -19,6 +19,10 @@ export declare function resolveRetryPolicy(options: CursorRetryOptions | undefin
19
19
  export declare function connectFrameError(payload: string): CursorProviderError;
20
20
  /** Restore the last real catalog solely for lifecycle turns such as compaction. */
21
21
  export declare function restoreTurnToolCatalog(sessionKey: string, tools: OpencodeToolDef[]): void;
22
+ /** Record the authoritative host todo list for an OpenCode session. */
23
+ export declare function rememberMirroredTodos(sessionKey: string | undefined, todos: ReadonlyArray<Record<string, unknown>>): void;
24
+ /** Copy of the last known host todo list for an OpenCode session, if any. */
25
+ export declare function snapshotMirroredTodosBySession(sessionKey: string | undefined): Array<Record<string, unknown>> | undefined;
22
26
  type V3Part = LanguageModelV3StreamPart;
23
27
  export declare function createCursorLanguageModel(modelId: string, providerId: string, options: CreateCursorOptions): LanguageModelV3;
24
28
  export declare function pumpWithRecovery(input: {
@@ -11,7 +11,7 @@ import { buildGitDiffExecMessages } from "./protocol/git-diff.js";
11
11
  import { cursorExecVariantByRequestField, describeCursorExecVariant } from "./protocol/exec-variants.js";
12
12
  import { appendWorkspaceRootGrounding } from "./protocol/workspace-grounding.js";
13
13
  import { progressOnlyContinuationPrompt, shouldContinueProgressOnlyTurn, } from "./protocol/progress-continuation.js";
14
- import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
14
+ import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, snapshotMirroredTodos, snapshotMirroredTodosFromReadOutput, } from "./protocol/tool-call-bridge.js";
15
15
  import { handleKvServerMessage } from "./protocol/kv.js";
16
16
  import { buildAskQuestionInteractionReply, buildAsyncAskQuestionCompletion, buildCreatePlanInteractionReply, buildSwitchModeInteractionReply, handleInteractionQuery, } from "./protocol/interactions.js";
17
17
  import { ASK_QUESTION_RESULT_FIELD, askQuestionResultFromToolOutput, askQuestionToolInput, } from "./protocol/ask-question.js";
@@ -326,6 +326,43 @@ function snapshotToolCatalog(sessionKey) {
326
326
  return [];
327
327
  return structuredClone(toolCatalogBySession.get(sessionKey) ?? []);
328
328
  }
329
+ // Last known full host todo list per OpenCode session. Run-local
330
+ // `session.mirroredTodos` dies with the Run (turn_ended closes the session),
331
+ // but merges in later turns still need a base — same shape of problem as the
332
+ // tool catalog above, same solution. In-memory only: never enters the prompt,
333
+ // RequestContext, or persisted restart state, so the cache prefix is untouched.
334
+ // Refreshed on every todo write/read we observe; replaced wholesale, never
335
+ // merged, so it tracks host truth instead of accumulating guesses.
336
+ const mirroredTodosBySession = new Map();
337
+ /** Record the authoritative host todo list for an OpenCode session. */
338
+ export function rememberMirroredTodos(sessionKey, todos) {
339
+ if (!sessionKey)
340
+ return;
341
+ mirroredTodosBySession.delete(sessionKey);
342
+ mirroredTodosBySession.set(sessionKey, todos.map((t) => ({ ...t })));
343
+ while (mirroredTodosBySession.size > MAX_TURN_STATE_SESSIONS) {
344
+ const oldest = mirroredTodosBySession.keys().next().value;
345
+ if (!oldest)
346
+ break;
347
+ mirroredTodosBySession.delete(oldest);
348
+ }
349
+ }
350
+ /** Copy of the last known host todo list for an OpenCode session, if any. */
351
+ export function snapshotMirroredTodosBySession(sessionKey) {
352
+ if (!sessionKey)
353
+ return undefined;
354
+ const todos = mirroredTodosBySession.get(sessionKey);
355
+ return todos ? todos.map((t) => ({ ...t })) : undefined;
356
+ }
357
+ /**
358
+ * Store a bridged/observed full todo snapshot on both the live Run session
359
+ * (for merges later this turn) and the per-OpenCode-session copy (for merges
360
+ * in later turns and across checkpoint resumes).
361
+ */
362
+ function storeMirroredTodos(session, todos) {
363
+ session.mirroredTodos = todos.map((t) => ({ ...t }));
364
+ rememberMirroredTodos(session.openCodeSessionId, session.mirroredTodos);
365
+ }
329
366
  function rememberPostCompactionRebase(sessionKey) {
330
367
  postCompactionRebaseBySession.delete(sessionKey);
331
368
  postCompactionRebaseBySession.add(sessionKey);
@@ -573,6 +610,8 @@ export async function pumpWithRecovery(input) {
573
610
  if (recovery.kind === "resume") {
574
611
  session.usageEstimate = { ...pumpedSession.usageEstimate };
575
612
  session.editToolCalls = new Map(pumpedSession.editToolCalls);
613
+ // mirroredTodos rides along via rememberMirroredTodos (per-OpenCode-
614
+ // session, seeded in startSession) — no handoff needed here.
576
615
  }
577
616
  input.onSession?.(session);
578
617
  }
@@ -977,6 +1016,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
977
1016
  pending: new Map(),
978
1017
  displayToolCalls: new Map(),
979
1018
  editToolCalls: new Map(),
1019
+ // Seed from the per-OpenCode-session copy: merges in this turn (and after
1020
+ // checkpoint resumes/rebases, which rebuild the session here) expand
1021
+ // against the last observed host list, not an empty one.
1022
+ mirroredTodos: snapshotMirroredTodosBySession(lifecycle ? undefined : sessionKey),
980
1023
  nextBridgedExecId: 900_000,
981
1024
  blobs: new Map(),
982
1025
  toolDescriptors,
@@ -1362,6 +1405,16 @@ export function deliverContinuationResults(session, trailingToolResults) {
1362
1405
  continue;
1363
1406
  return undefined;
1364
1407
  }
1408
+ // A host `todoread` result is the authoritative list. Refresh the mirrored
1409
+ // snapshot (direct reads and bridged native reads alike) so later merges
1410
+ // apply onto host truth, not a stale write.
1411
+ if (pending.toolName === "todoread" && r.error === undefined) {
1412
+ const snapshot = snapshotMirroredTodosFromReadOutput(r.output);
1413
+ if (snapshot !== undefined) {
1414
+ storeMirroredTodos(session, snapshot);
1415
+ trace(`continuation: mirrored todoread snapshot items=${snapshot.length}`);
1416
+ }
1417
+ }
1365
1418
  session.usageEstimate.inputTokens += estimateTokens(r.output.length);
1366
1419
  if (pending.bridged) {
1367
1420
  trace(`continuation: completed bridged result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
@@ -2113,7 +2166,7 @@ export async function pump(session, controller, ids, abortSignal) {
2113
2166
  trace(`display tool_call_completed: SKIPPED (allowTools=false) callId=${callId}`);
2114
2167
  }
2115
2168
  else {
2116
- const display = parseDisplayToolCall(callId, toolCall);
2169
+ const display = parseDisplayToolCall(callId, toolCall, session.mirroredTodos);
2117
2170
  const advertised = advertisedToolNamesFromDescriptors(session.toolDescriptors);
2118
2171
  const bridged = display
2119
2172
  ? resolveBridgedOpenCodeToolCall(display, advertised)
@@ -2134,6 +2187,13 @@ export async function pump(session, controller, ids, abortSignal) {
2134
2187
  `advertised=[${advertised.join(",")}]`);
2135
2188
  }
2136
2189
  else {
2190
+ // Snapshot only Cursor todo writes (not create_plan's synthetic
2191
+ // "plan" prepend) so later merge patches apply onto a real list.
2192
+ if (bridged.toolName === "todowrite" && bridged.variant === "update_todos_tool_call") {
2193
+ const snapshot = snapshotMirroredTodos(bridged.args.todos);
2194
+ if (snapshot !== undefined)
2195
+ storeMirroredTodos(session, snapshot);
2196
+ }
2137
2197
  const execId = session.nextBridgedExecId++;
2138
2198
  sessionManager.registerPending(execId, session, "bridged", bridged.toolName, true);
2139
2199
  const toolCallId = `cursor_${session.sessionId}_${execId}`;
@@ -2396,6 +2456,16 @@ export async function pump(session, controller, ids, abortSignal) {
2396
2456
  }
2397
2457
  // Keep the stream open; the result arrives on the next doStream call.
2398
2458
  sessionManager.registerPending(parsed.id, session, parsed.resultField, parsed.toolName, false, parsed.resultMetadata);
2459
+ // A direct host `todowrite` is a replace-all snapshot: it is the new
2460
+ // truth for later Cursor merge patches, which otherwise apply onto a
2461
+ // stale (or empty) mirrored list.
2462
+ if (parsed.toolName === "todowrite") {
2463
+ const snapshot = snapshotMirroredTodos(parsed.args.todos);
2464
+ if (snapshot !== undefined) {
2465
+ storeMirroredTodos(session, snapshot);
2466
+ trace(`exec: mirrored todowrite snapshot items=${snapshot.length}`);
2467
+ }
2468
+ }
2399
2469
  // tc.input is already a JSON string (LanguageModelV3ToolCall.input).
2400
2470
  trace(`exec: EMITTED tool-call toolCallId=${tc.toolCallId} toolName=${tc.toolName} inputLen=${tc.input.length}`);
2401
2471
  emittedHostTools++;
@@ -2781,12 +2851,18 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
2781
2851
  if (names.has("plan_enter")) {
2782
2852
  instructions.push("- To enter plan mode, call the OpenCode `plan_enter` tool. Cursor-native SwitchMode requests for plan/spec are also accepted and answered through it.");
2783
2853
  }
2784
- else if (names.has("todowrite")) {
2785
- instructions.push("- For planning task lists, call the OpenCode `todowrite` tool and/or write the plan as normal markdown.");
2786
- }
2787
2854
  if (names.has("plan_exit")) {
2788
2855
  instructions.push("- To leave plan mode, call the OpenCode `plan_exit` tool. Cursor-native SwitchMode for any non-plan target (agent, build, chat, debug, edit, background, multitask, triage, project, …) is also accepted and answered through it; the provider then injects the Cursor CLI-shaped mode reminder for that target.");
2789
2856
  }
2857
+ // Prefer host todos whenever advertised. Keep guidance terse — spelling out
2858
+ // Cursor TodoWrite vs TodoRead / MCP alias collisions teaches the model to
2859
+ // inventory catalogs instead of just calling the host tools.
2860
+ if (names.has("todowrite") || names.has("todoread")) {
2861
+ const write = names.has("todowrite") ? "`todowrite`" : undefined;
2862
+ const read = names.has("todoread") ? "`todoread`" : undefined;
2863
+ const tools = write && read ? `${write} / ${read}` : (write ?? read);
2864
+ instructions.push(`- For task-list create/update/complete/cancel${read ? "/read" : ""}, call OpenCode ${tools}; do not use Cursor TodoWrite, and do not narrate Cursor-vs-OpenCode todo-tool differences.`);
2865
+ }
2790
2866
  if (names.has(CUSTOM_WEBSEARCH_TOOL)) {
2791
2867
  instructions.push(`- For web searches, call \`${CUSTOM_WEBSEARCH_TOOL}\`; do not use Cursor's native WebSearch interaction.`);
2792
2868
  }
@@ -2828,8 +2904,8 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
2828
2904
  `OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
2829
2905
  `Workspace root: ${JSON.stringify(workspaceRoot)}. Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them.`,
2830
2906
  subagents.executor
2831
- ? "Call only tools in that exact list for ordinary host execution. Cursor-native Task/subagent requests are permitted because a compatible host executor is listed. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing."
2832
- : "Call only tools in that exact list for ordinary host execution. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing. Other unlisted Cursor-native tools are not bridged; complete the work with the listed tools or explain the limitation without claiming a missing MCP tool.",
2907
+ ? "Call only tools in that exact OpenCode list for ordinary host execution. Cursor-native Task/subagent requests are permitted because a compatible host executor is listed. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing."
2908
+ : "Call only tools in that exact OpenCode list for ordinary host execution. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing. Other unlisted Cursor-native tools are not bridged; complete the work with the listed tools or explain the limitation without claiming a missing MCP tool.",
2833
2909
  ...(instructions.length > 0
2834
2910
  ? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
2835
2911
  : []),
@@ -125,6 +125,11 @@ export declare const CURSOR_MODEL_COSTS: {
125
125
  readonly output: 3.5;
126
126
  readonly cache_read: 0.075;
127
127
  };
128
+ readonly "gemini-3.8-flash": {
129
+ readonly input: 0.75;
130
+ readonly output: 3.5;
131
+ readonly cache_read: 0.075;
132
+ };
128
133
  readonly "glm-5.2": {
129
134
  readonly input: 1.4;
130
135
  readonly output: 4.4;
@@ -185,6 +190,12 @@ export declare const CURSOR_MODEL_COSTS: {
185
190
  readonly output: 1.2;
186
191
  readonly cache_read: 0.02;
187
192
  readonly cache_write: 0.25;
193
+ readonly context_over_200k: {
194
+ readonly input: 0.4;
195
+ readonly output: 1.2;
196
+ readonly cache_read: 0.04;
197
+ readonly cache_write: 0.5;
198
+ };
188
199
  };
189
200
  readonly "gpt-5.6-sol": {
190
201
  readonly input: 4;
@@ -203,6 +214,12 @@ export declare const CURSOR_MODEL_COSTS: {
203
214
  readonly output: 12;
204
215
  readonly cache_read: 0.2;
205
216
  readonly cache_write: 2.5;
217
+ readonly context_over_200k: {
218
+ readonly input: 4;
219
+ readonly output: 12;
220
+ readonly cache_read: 0.4;
221
+ readonly cache_write: 5;
222
+ };
206
223
  };
207
224
  readonly "grok-4.5": {
208
225
  readonly input: 2;
@@ -234,6 +251,11 @@ export declare const CURSOR_MODEL_COSTS: {
234
251
  readonly output: 15;
235
252
  readonly cache_read: 0.3;
236
253
  };
254
+ readonly "muse-spark-1.3": {
255
+ readonly input: 1.25;
256
+ readonly output: 4.25;
257
+ readonly cache_read: 0.15;
258
+ };
237
259
  };
238
260
  export declare const CURSOR_MODEL_CONTEXTS: {
239
261
  readonly "claude-fable-5": {
@@ -310,6 +332,10 @@ export declare const CURSOR_MODEL_CONTEXTS: {
310
332
  readonly maxContext: 200000;
311
333
  readonly maxContextForMaxMode: 1000000;
312
334
  };
335
+ readonly "gemini-3.8-flash": {
336
+ readonly maxContext: 200000;
337
+ readonly maxContextForMaxMode: 1000000;
338
+ };
313
339
  readonly "glm-5.2": {
314
340
  readonly maxContext: 200000;
315
341
  };
@@ -341,6 +367,7 @@ export declare const CURSOR_MODEL_CONTEXTS: {
341
367
  };
342
368
  readonly "gpt-5.6-luna": {
343
369
  readonly maxContext: 272000;
370
+ readonly maxContextForMaxMode: 1000000;
344
371
  };
345
372
  readonly "gpt-5.6-sol": {
346
373
  readonly maxContext: 272000;
@@ -348,6 +375,7 @@ export declare const CURSOR_MODEL_CONTEXTS: {
348
375
  };
349
376
  readonly "gpt-5.6-terra": {
350
377
  readonly maxContext: 272000;
378
+ readonly maxContextForMaxMode: 1000000;
351
379
  };
352
380
  readonly "grok-4.5": {
353
381
  readonly maxContext: 256000;
@@ -362,6 +390,10 @@ export declare const CURSOR_MODEL_CONTEXTS: {
362
390
  readonly maxContext: 200000;
363
391
  readonly maxContextForMaxMode: 1000000;
364
392
  };
393
+ readonly "muse-spark-1.3": {
394
+ readonly maxContext: 300000;
395
+ readonly maxContextForMaxMode: 1000000;
396
+ };
365
397
  };
366
398
  export declare const CURSOR_MODEL_CAPABILITIES: {
367
399
  readonly "claude-fable-5": {
@@ -421,6 +453,9 @@ export declare const CURSOR_MODEL_CAPABILITIES: {
421
453
  readonly "gemini-3.7-flash": {
422
454
  readonly supportsImages: true;
423
455
  };
456
+ readonly "gemini-3.8-flash": {
457
+ readonly supportsImages: true;
458
+ };
424
459
  readonly "glm-5.2": {
425
460
  readonly supportsImages: false;
426
461
  };
@@ -469,4 +504,7 @@ export declare const CURSOR_MODEL_CAPABILITIES: {
469
504
  readonly "kimi-k3": {
470
505
  readonly supportsImages: true;
471
506
  };
507
+ readonly "muse-spark-1.3": {
508
+ readonly supportsImages: true;
509
+ };
472
510
  };
@@ -125,6 +125,11 @@ export const CURSOR_MODEL_COSTS = {
125
125
  "output": 3.5,
126
126
  "cache_read": 0.075
127
127
  },
128
+ "gemini-3.8-flash": {
129
+ "input": 0.75,
130
+ "output": 3.5,
131
+ "cache_read": 0.075
132
+ },
128
133
  "glm-5.2": {
129
134
  "input": 1.4,
130
135
  "output": 4.4,
@@ -184,7 +189,13 @@ export const CURSOR_MODEL_COSTS = {
184
189
  "input": 0.2,
185
190
  "output": 1.2,
186
191
  "cache_read": 0.02,
187
- "cache_write": 0.25
192
+ "cache_write": 0.25,
193
+ "context_over_200k": {
194
+ "input": 0.4,
195
+ "output": 1.2,
196
+ "cache_read": 0.04,
197
+ "cache_write": 0.5
198
+ }
188
199
  },
189
200
  "gpt-5.6-sol": {
190
201
  "input": 4,
@@ -202,7 +213,13 @@ export const CURSOR_MODEL_COSTS = {
202
213
  "input": 2,
203
214
  "output": 12,
204
215
  "cache_read": 0.2,
205
- "cache_write": 2.5
216
+ "cache_write": 2.5,
217
+ "context_over_200k": {
218
+ "input": 4,
219
+ "output": 12,
220
+ "cache_read": 0.4,
221
+ "cache_write": 5
222
+ }
206
223
  },
207
224
  "grok-4.5": {
208
225
  "input": 2,
@@ -233,6 +250,11 @@ export const CURSOR_MODEL_COSTS = {
233
250
  "input": 3,
234
251
  "output": 15,
235
252
  "cache_read": 0.3
253
+ },
254
+ "muse-spark-1.3": {
255
+ "input": 1.25,
256
+ "output": 4.25,
257
+ "cache_read": 0.15
236
258
  }
237
259
  };
238
260
  export const CURSOR_MODEL_CONTEXTS = {
@@ -310,6 +332,10 @@ export const CURSOR_MODEL_CONTEXTS = {
310
332
  "maxContext": 200000,
311
333
  "maxContextForMaxMode": 1000000
312
334
  },
335
+ "gemini-3.8-flash": {
336
+ "maxContext": 200000,
337
+ "maxContextForMaxMode": 1000000
338
+ },
313
339
  "glm-5.2": {
314
340
  "maxContext": 200000
315
341
  },
@@ -340,14 +366,16 @@ export const CURSOR_MODEL_CONTEXTS = {
340
366
  "maxContextForMaxMode": 1000000
341
367
  },
342
368
  "gpt-5.6-luna": {
343
- "maxContext": 272000
369
+ "maxContext": 272000,
370
+ "maxContextForMaxMode": 1000000
344
371
  },
345
372
  "gpt-5.6-sol": {
346
373
  "maxContext": 272000,
347
374
  "maxContextForMaxMode": 1000000
348
375
  },
349
376
  "gpt-5.6-terra": {
350
- "maxContext": 272000
377
+ "maxContext": 272000,
378
+ "maxContextForMaxMode": 1000000
351
379
  },
352
380
  "grok-4.5": {
353
381
  "maxContext": 256000
@@ -361,6 +389,10 @@ export const CURSOR_MODEL_CONTEXTS = {
361
389
  "kimi-k3": {
362
390
  "maxContext": 200000,
363
391
  "maxContextForMaxMode": 1000000
392
+ },
393
+ "muse-spark-1.3": {
394
+ "maxContext": 300000,
395
+ "maxContextForMaxMode": 1000000
364
396
  }
365
397
  };
366
398
  export const CURSOR_MODEL_CAPABILITIES = {
@@ -421,6 +453,9 @@ export const CURSOR_MODEL_CAPABILITIES = {
421
453
  "gemini-3.7-flash": {
422
454
  "supportsImages": true
423
455
  },
456
+ "gemini-3.8-flash": {
457
+ "supportsImages": true
458
+ },
424
459
  "glm-5.2": {
425
460
  "supportsImages": false
426
461
  },
@@ -468,5 +503,8 @@ export const CURSOR_MODEL_CAPABILITIES = {
468
503
  },
469
504
  "kimi-k3": {
470
505
  "supportsImages": true
506
+ },
507
+ "muse-spark-1.3": {
508
+ "supportsImages": true
471
509
  }
472
510
  };
@@ -257,11 +257,24 @@ export function createMessageTypes() {
257
257
  addType(root, "PiFindToolCall", [{ id: 1, name: "args", type: "PiFindToolArgs" }]);
258
258
  addType(root, "PiLsToolCall", [{ id: 1, name: "args", type: "PiLsToolArgs" }]);
259
259
  // Display-only native tools Cursor may complete without an ExecServerMessage.
260
+ // ReadTodos mirrors UpdateTodos: args + result (see agent.v1 ReadTodosToolCall).
260
261
  addType(root, "ReadTodosArgs", [
261
262
  { id: 1, name: "status_filter", type: "TodoStatus", repeated: true },
262
263
  { id: 2, name: "id_filter", type: "string", repeated: true },
263
264
  ]);
264
- addType(root, "ReadTodosToolCall", [{ id: 1, name: "args", type: "ReadTodosArgs" }]);
265
+ addType(root, "ReadTodosSuccess", [
266
+ { id: 1, name: "todos", type: "TodoItem", repeated: true },
267
+ { id: 2, name: "total_count", type: "int32" },
268
+ ]);
269
+ addType(root, "ReadTodosError", [{ id: 1, name: "error", type: "string" }]);
270
+ addType(root, "ReadTodosResult", [
271
+ { id: 1, name: "success", type: "ReadTodosSuccess" },
272
+ { id: 2, name: "error", type: "ReadTodosError" },
273
+ ], [{ name: "result", fields: ["success", "error"] }]);
274
+ addType(root, "ReadTodosToolCall", [
275
+ { id: 1, name: "args", type: "ReadTodosArgs" },
276
+ { id: 2, name: "result", type: "ReadTodosResult" },
277
+ ]);
265
278
  addType(root, "AwaitArgs", [
266
279
  { id: 1, name: "task_id", type: "string" },
267
280
  { id: 2, name: "block_until_ms", type: "uint32" },
@@ -19,10 +19,38 @@ export type BridgedOpenCodeToolCall = {
19
19
  callId: string;
20
20
  variant: string;
21
21
  };
22
+ /**
23
+ * Normalize a full todo snapshot for `mirroredTodos` storage. Returns
24
+ * undefined when the input is not a todo array (nothing to learn). Drops the
25
+ * synthetic "plan" id minted for create_plan mirrors so later merges never
26
+ * resurrect it.
27
+ */
28
+ export declare function snapshotMirroredTodos(todos: unknown): Array<Record<string, unknown>> | undefined;
29
+ /**
30
+ * Refresh the mirrored snapshot from a host `todoread` tool result. The host
31
+ * list is authoritative; returns undefined when the output is not recognizable
32
+ * todo JSON (prose, caps, errors) so a stale-but-useful snapshot survives.
33
+ */
34
+ export declare function snapshotMirroredTodosFromReadOutput(output: string): Array<Record<string, unknown>> | undefined;
35
+ /**
36
+ * Overlay Cursor merge patches onto a previously mirrored full todo list.
37
+ * Preserves prior order; appends newly introduced ids in patch order.
38
+ *
39
+ * Matching is by id first, then by exact (trimmed) content. The content
40
+ * fallback covers mixed flows where the prior snapshot came from a direct
41
+ * host `todowrite` (positional ids) while the patch references Cursor-side
42
+ * ids for the same tasks. Patch items with neither an id/content match nor
43
+ * their own content are skipped — a status-only update for an unknown item
44
+ * cannot be mapped onto the host list.
45
+ */
46
+ export declare function applyTodoMerge(prior: ReadonlyArray<Record<string, unknown>>, patch: unknown): Array<Record<string, unknown>>;
22
47
  /**
23
48
  * Decode a Cursor ToolCall oneof into a display tool call we can bridge.
49
+ *
50
+ * @param priorMirroredTodos Last full todo snapshot mirrored this Run; used to
51
+ * expand Cursor `merge: true` updates that omit ToolCallCompleted.success.todos.
24
52
  */
25
- export declare function parseDisplayToolCall(callId: string, toolCall: Record<string, unknown> | undefined): DisplayToolCall | undefined;
53
+ export declare function parseDisplayToolCall(callId: string, toolCall: Record<string, unknown> | undefined, priorMirroredTodos?: ReadonlyArray<Record<string, unknown>>): DisplayToolCall | undefined;
26
54
  /**
27
55
  * Pick an advertised OpenCode tool for a display call, remapping args as needed.
28
56
  * Returns undefined when nothing compatible is advertised this turn.
@@ -46,11 +46,13 @@ const VARIANT_TO_OPENCODE = {
46
46
  const TOOL_CALL_VARIANTS = Object.keys(VARIANT_TO_OPENCODE);
47
47
  // ToolCallCompleted is a notification, not an execution request. Only mirror
48
48
  // client-visible state whose authoritative final value is already present in
49
- // the completed payload. Data-returning, interactive, and side-effecting calls
49
+ // the completed payload (or reconstructable from a prior mirrored snapshot for
50
+ // Cursor todo merges). Data-returning, interactive, and side-effecting calls
50
51
  // must use an exec/interaction request channel where Cursor can receive their
51
52
  // actual result.
52
53
  const DISPLAY_STATE_MIRROR_VARIANTS = new Set([
53
54
  "update_todos_tool_call",
55
+ "read_todos_tool_call",
54
56
  "create_plan_tool_call",
55
57
  ]);
56
58
  function asRecord(v) {
@@ -97,14 +99,169 @@ function mapTodos(raw) {
97
99
  };
98
100
  });
99
101
  }
102
+ /**
103
+ * Normalize a full todo snapshot for `mirroredTodos` storage. Returns
104
+ * undefined when the input is not a todo array (nothing to learn). Drops the
105
+ * synthetic "plan" id minted for create_plan mirrors so later merges never
106
+ * resurrect it.
107
+ */
108
+ export function snapshotMirroredTodos(todos) {
109
+ if (!Array.isArray(todos))
110
+ return undefined;
111
+ return mapTodos(todos).filter((t) => {
112
+ const id = typeof t.id === "string" ? t.id : "";
113
+ return id.length > 0 && id !== "plan";
114
+ });
115
+ }
116
+ /**
117
+ * Refresh the mirrored snapshot from a host `todoread` tool result. The host
118
+ * list is authoritative; returns undefined when the output is not recognizable
119
+ * todo JSON (prose, caps, errors) so a stale-but-useful snapshot survives.
120
+ */
121
+ export function snapshotMirroredTodosFromReadOutput(output) {
122
+ let parsed;
123
+ try {
124
+ parsed = JSON.parse(output);
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ const list = Array.isArray(parsed) ? parsed : (asRecord(parsed)?.todos ?? undefined);
130
+ if (!Array.isArray(list))
131
+ return undefined;
132
+ return snapshotMirroredTodos(list);
133
+ }
134
+ /**
135
+ * Overlay Cursor merge patches onto a previously mirrored full todo list.
136
+ * Preserves prior order; appends newly introduced ids in patch order.
137
+ *
138
+ * Matching is by id first, then by exact (trimmed) content. The content
139
+ * fallback covers mixed flows where the prior snapshot came from a direct
140
+ * host `todowrite` (positional ids) while the patch references Cursor-side
141
+ * ids for the same tasks. Patch items with neither an id/content match nor
142
+ * their own content are skipped — a status-only update for an unknown item
143
+ * cannot be mapped onto the host list.
144
+ */
145
+ export function applyTodoMerge(prior, patch) {
146
+ const rawPatch = Array.isArray(patch) ? patch : [];
147
+ const mappedPatch = mapTodos(patch);
148
+ const byId = new Map();
149
+ const byContent = new Map();
150
+ // Patch ids already folded into a prior record via content match: never
151
+ // re-emit them as new items in the output phase.
152
+ const consumedPatchIds = new Set();
153
+ // Patch ids accepted as genuinely new content: the only patch ids the
154
+ // output phase may append. Orphans skipped above (unknown id, no content)
155
+ // must not leak back in here.
156
+ const appendedPatchIds = new Set();
157
+ for (const item of prior) {
158
+ const id = typeof item.id === "string" && item.id.length > 0 ? item.id : "";
159
+ if (!id)
160
+ continue;
161
+ const normalized = {
162
+ id,
163
+ content: typeof item.content === "string" ? item.content : "",
164
+ status: typeof item.status === "string" ? item.status : mapTodoStatus(item.status),
165
+ priority: typeof item.priority === "string" && item.priority ? item.priority : "medium",
166
+ };
167
+ byId.set(id, normalized);
168
+ const key = normalized.content.trim();
169
+ if (key && !byContent.has(key))
170
+ byContent.set(key, normalized);
171
+ }
172
+ const overlay = (target, item, raw) => {
173
+ // Only overwrite fields the patch actually carries. mapTodos fills
174
+ // defaults ("" content, "pending" status, "medium" priority) that must not
175
+ // clobber a prior item when Cursor sends an id+status-only patch.
176
+ const next = { ...target, id: target.id };
177
+ if (typeof raw.content === "string" && raw.content.length > 0) {
178
+ next.content = raw.content;
179
+ }
180
+ if (raw.status !== undefined && raw.status !== null) {
181
+ next.status = String(item.status);
182
+ }
183
+ if (typeof raw.priority === "string" && raw.priority) {
184
+ next.priority = raw.priority;
185
+ }
186
+ return next;
187
+ };
188
+ for (let index = 0; index < mappedPatch.length; index++) {
189
+ const item = mappedPatch[index];
190
+ const raw = asRecord(rawPatch[index]) ?? {};
191
+ const id = String(item.id);
192
+ const byIdMatch = byId.get(id);
193
+ if (byIdMatch) {
194
+ const merged = overlay(byIdMatch, item, raw);
195
+ byId.set(id, merged);
196
+ // Keep the content index pointing at the fresh record so later
197
+ // content-fallback matches never merge onto a stale copy.
198
+ const oldKey = typeof byIdMatch.content === "string" ? byIdMatch.content.trim() : "";
199
+ const newKey = typeof merged.content === "string" ? merged.content.trim() : "";
200
+ if (oldKey && byContent.get(oldKey) === byIdMatch)
201
+ byContent.delete(oldKey);
202
+ if (newKey && !byContent.has(newKey))
203
+ byContent.set(newKey, merged);
204
+ continue;
205
+ }
206
+ const contentKey = typeof raw.content === "string" && raw.content.trim().length > 0
207
+ ? raw.content.trim()
208
+ : "";
209
+ const byContentMatch = contentKey ? byContent.get(contentKey) : undefined;
210
+ if (byContentMatch) {
211
+ // Same task, different id space (host positional ids vs Cursor ids).
212
+ // Keep the prior id so snapshot identity stays stable and order is
213
+ // preserved; adopt the patch's status/priority/content.
214
+ const merged = overlay(byContentMatch, item, raw);
215
+ byId.set(String(byContentMatch.id), merged);
216
+ if (contentKey)
217
+ byContent.set(contentKey, merged);
218
+ consumedPatchIds.add(id);
219
+ continue;
220
+ }
221
+ if (!contentKey)
222
+ continue;
223
+ byId.set(id, item);
224
+ byContent.set(contentKey, item);
225
+ appendedPatchIds.add(id);
226
+ }
227
+ const out = [];
228
+ const seen = new Set();
229
+ for (const item of prior) {
230
+ const id = typeof item.id === "string" ? item.id : "";
231
+ if (!id || seen.has(id))
232
+ continue;
233
+ const next = byId.get(id);
234
+ if (next) {
235
+ out.push(next);
236
+ seen.add(id);
237
+ }
238
+ }
239
+ for (const item of mappedPatch) {
240
+ const id = String(item.id);
241
+ // Id-matched items are already emitted under the prior id; content-matched
242
+ // items were folded into the prior record (not appended under the foreign
243
+ // patch id); orphans were skipped outright. Only genuinely new content
244
+ // reaches the host list.
245
+ if (seen.has(id) || consumedPatchIds.has(id))
246
+ continue;
247
+ if (!appendedPatchIds.has(id))
248
+ continue;
249
+ out.push(item);
250
+ seen.add(id);
251
+ }
252
+ return out;
253
+ }
100
254
  function unwrapArgs(variantPayload) {
101
255
  const nested = asRecord(variantPayload.args);
102
256
  return nested ?? variantPayload;
103
257
  }
104
258
  /**
105
259
  * Decode a Cursor ToolCall oneof into a display tool call we can bridge.
260
+ *
261
+ * @param priorMirroredTodos Last full todo snapshot mirrored this Run; used to
262
+ * expand Cursor `merge: true` updates that omit ToolCallCompleted.success.todos.
106
263
  */
107
- export function parseDisplayToolCall(callId, toolCall) {
264
+ export function parseDisplayToolCall(callId, toolCall, priorMirroredTodos) {
108
265
  if (!toolCall || !callId)
109
266
  return undefined;
110
267
  const variant = findToolVariant(toolCall);
@@ -136,10 +293,16 @@ export function parseDisplayToolCall(callId, toolCall) {
136
293
  const success = asRecord(result?.success);
137
294
  const completedTodos = Array.isArray(success?.todos) ? success.todos : undefined;
138
295
  const isMerge = variant === "update_todos_tool_call" && args.merge === true;
139
- // OpenCode todowrite replaces the whole list. For Cursor merge updates,
140
- // only bridge when ToolCallCompleted includes the final merged list.
296
+ // OpenCode todowrite replaces the whole list. Prefer the completed payload;
297
+ // otherwise a non-merge args list; otherwise merge against the Run snapshot.
141
298
  const sourceTodos = completedTodos ?? (!isMerge ? args.todos : undefined);
142
- const todos = mapTodos(sourceTodos);
299
+ const canMergeFromPrior = sourceTodos === undefined &&
300
+ isMerge &&
301
+ Array.isArray(priorMirroredTodos) &&
302
+ priorMirroredTodos.length > 0;
303
+ const todos = canMergeFromPrior
304
+ ? applyTodoMerge(priorMirroredTodos, args.todos)
305
+ : mapTodos(sourceTodos);
143
306
  if (variant === "create_plan_tool_call") {
144
307
  const overview = typeof args.overview === "string" ? args.overview.trim() : "";
145
308
  const plan = typeof args.plan === "string" ? args.plan.trim() : "";
@@ -158,7 +321,21 @@ export function parseDisplayToolCall(callId, toolCall) {
158
321
  variant,
159
322
  preferredToolName: "todowrite",
160
323
  args: { todos },
161
- bridgeable: variant === "create_plan_tool_call" ? todos.length > 0 : Array.isArray(sourceTodos),
324
+ bridgeable: variant === "create_plan_tool_call"
325
+ ? todos.length > 0
326
+ : Array.isArray(sourceTodos) || canMergeFromPrior,
327
+ };
328
+ }
329
+ // Cursor TodoRead is the same display-only channel as TodoWrite (agent.v1
330
+ // read_todos_tool_call). OpenCode todoread takes an empty body; host filters
331
+ // are not part of the canonical schema, so drop status_filter / id_filter.
332
+ if (variant === "read_todos_tool_call") {
333
+ return {
334
+ callId,
335
+ variant,
336
+ preferredToolName: "todoread",
337
+ args: {},
338
+ bridgeable: true,
162
339
  };
163
340
  }
164
341
  if (variant === "edit_tool_call") {
@@ -295,6 +472,9 @@ export function resolveBridgedOpenCodeToolCall(display, advertised) {
295
472
  todos: mapTodos(display.args.todos ?? mapped.args.todos),
296
473
  };
297
474
  }
475
+ if (toolName === "todoread") {
476
+ mapped.args = {};
477
+ }
298
478
  return {
299
479
  toolName: mapped.toolName,
300
480
  args: mapped.args,
package/dist/session.d.ts CHANGED
@@ -147,6 +147,12 @@ export type CursorSession = {
147
147
  * or when we bridge the completed display call into an OpenCode tool-call.
148
148
  */
149
149
  displayToolCalls: Map<string, Record<string, unknown>>;
150
+ /**
151
+ * Last full todo snapshot successfully mirrored into OpenCode `todowrite`.
152
+ * Used to apply Cursor `merge: true` display updates that omit the final
153
+ * completed list so host todos still receive a replace-all snapshot.
154
+ */
155
+ mirroredTodos?: Array<Record<string, unknown>>;
150
156
  /**
151
157
  * Legacy edit calls whose authoritative exec path is still in progress.
152
158
  * Cursor implements these as read -> whole-file write; retaining the path
package/dist/session.js CHANGED
@@ -387,6 +387,7 @@ export class SessionManager {
387
387
  session.pumpOwner = null;
388
388
  session.pumpActive = false;
389
389
  session.displayToolCalls?.clear();
390
+ session.mirroredTodos = undefined;
390
391
  session.blobs?.clear();
391
392
  this.sessions.delete(session);
392
393
  if (session.openCodeSessionId &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",