cursor-opencode-provider 0.4.0 → 0.4.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
@@ -48,7 +48,7 @@ Add the package to OpenCode config. OpenCode installs npm plugins with Bun at st
48
48
  }
49
49
  ```
50
50
 
51
- Pin a version if you want: `"cursor-opencode-provider@0.4.0"`.
51
+ Pin a version if you want: `"cursor-opencode-provider@0.4.1"`.
52
52
 
53
53
  You can also install it yourself first:
54
54
 
@@ -118,7 +118,7 @@ browser login or an API key. `CURSOR_API_KEY` is also picked up automatically.
118
118
  }
119
119
  ```
120
120
 
121
- Pin a version if you want: `"cursor-opencode-provider@0.4.0/plugin/opencode2"`.
121
+ Pin a version if you want: `"cursor-opencode-provider@0.4.1/plugin/opencode2"`.
122
122
 
123
123
  OpenCode 2.0 installs the published package into its host cache and loads the AI SDK
124
124
  entry from there (`aisdk:cursor-opencode-provider`). No extra env vars are required.
@@ -379,11 +379,17 @@ The provider adds OpenCode-specific system guidance to normal tool-capable conve
379
379
 
380
380
  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).
381
381
 
382
+ ### Cursor edit handshakes
383
+
384
+ Cursor's legacy `edit_tool_call` is implemented internally as a correlated `read_args` followed by a whole-file `write_args`; the write is transport mechanics, not a new decision to replace OpenCode's `edit` tool. The provider retains that correlation and exposes an existing-file mutation as one unique, line-bounded OpenCode `edit`. It still returns Cursor's expected typed `write_result`. New-file creation remains a `write`, and an `apply_patch`-only catalog receives the same targeted change as an `*** Update File:` patch.
385
+
386
+ The prerequisite native read also preserves an existing final LF/CRLF. OpenCode's numbered read envelope cannot represent that terminator, and dropping it made Cursor's exact edit matcher reject valid replacements, retry reads, and eventually rewrite the file. This restoration applies only to confirmed-complete, unbounded reads; paged and capped reads keep their existing truncation behavior.
387
+
382
388
  ### `apply_patch` models (GPT-5 and friends)
383
389
 
384
390
  OpenCode 1.x does not simply add `apply_patch` for GPT-series models — it **removes** `edit` and `write` from the tool catalog and advertises `apply_patch` in their place (`ToolRegistry.tools`; the model id must contain `gpt-` and neither `oss` nor `gpt-4`). Cursor keeps sending its native write/edit requests regardless, so without translation every file change on a `gpt-5*` model is refused as an unavailable tool.
385
391
 
386
- The provider translates transparently: a native write becomes an `*** Add File:` patch, and a Pi edit becomes a minimal `*** Update File:` chunk widened to whole lines. This is keyed purely off what the host advertises — it is inert whenever `edit`/`write` are offered normally, and inert again when the host offers neither those nor `apply_patch`. An edit that cannot be expressed faithfully (target unreadable, or the text to replace is absent or ambiguous) is refused with a specific message rather than applied to the wrong region.
392
+ The provider translates transparently: an uncorrelated native write becomes an `*** Add File:` patch, while a correlated legacy edit or Pi edit becomes a minimal `*** Update File:` chunk widened to whole lines. This is keyed purely off what the host advertises — it is inert whenever `edit`/`write` are offered normally, and inert again when the host offers neither those nor `apply_patch`. An edit that cannot be expressed faithfully (target unreadable, or the text to replace is absent or ambiguous) is refused with a specific message rather than applied to the wrong region.
387
393
 
388
394
  OpenCode 2.0 does not perform this substitution, so the path is 1.x-only in practice. See [`src/protocol/apply-patch.ts`](src/protocol/apply-patch.ts) for the upstream references.
389
395
 
@@ -425,7 +431,8 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader.
425
431
  | Visible `<shell_metadata>` timeout text | Rebuild and restart a local install. Cursor's shell timeout is carried on its exec request; the provider now removes OpenCode's internal timeout envelope before it is rendered or stored, then returns Cursor's typed timeout or background-handoff event instead of treating the text as successful stdout. |
426
432
  | `Unsupported Cursor exec variant …` | The error names the canonical Cursor CLI request field, its expected result field, and this provider's handling classification. `handling=unsupported` is a known Cursor-native capability without a safe OpenCode AI SDK bridge; `unknown request field` indicates new protocol drift; `handling=opencode-tool` or `provider-control` indicates a provider decoder/dispatch regression. Enable the debug log and report the full named error. |
427
433
  | Need wire-level logs | Set `CURSOR_PROVIDER_DEBUG=1` (optional `CURSOR_PROVIDER_DEBUG_FILE`; the default is `debug-<pid>.log` under `$TMPDIR/cursor-provider-logs-<uid>/`) and reproduce the issue. |
428
- | Cache **write** tokens always show as `0` | Not a mapping bug in this provider. Cursor's `TurnEnded` protobuf includes `cache_write` (field 4) and this package forwards it as AI SDK `inputTokens.cacheWrite` / OpenCode `cache.write`. Real agent sessions send field 4 as `0` even when `cache_read` is large on later turns. With debug logging, confirm `turn_ended raw wire fields: … f4:wt0=0` and `v3CacheWrite=0 source=turn_ended`. Cache **read** counts from the same message do populate correctly. |
434
+ | OpenCode compacts immediately after a tool-heavy task | Upgrade to a version containing the cumulative-usage fix. Cursor's final `TurnEnded` counters cover the entire held multi-step Run; older provider versions exposed that aggregate as the final request's context size, which made OpenCode compact far too early. Fixed versions keep the raw counters in `providerMetadata.cursor` and report a request-local context estimate instead. |
435
+ | Cache **write** tokens always show as `0` | Cursor's `TurnEnded` protobuf includes `cache_write` (field 4), but captured Runs set it to `0` even when `cache_read` is large. Single-step Runs expose those counters directly. For multi-step Runs they remain in `providerMetadata.cursor` because Cursor reports only a cumulative whole-Run total, which cannot safely be used as one OpenCode request's context usage. With debug logging, confirm `turn_ended raw wire fields: … f4:wt0=0`. |
429
436
 
430
437
  ## Security
431
438
 
@@ -434,7 +441,7 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
434
441
  ## Known limitations
435
442
 
436
443
  - **Personal use / ToS** — this provider speaks Cursor’s private agent protocol (CLI-shaped client identity). Use only with an account you own; Cursor may change or restrict the API without notice.
437
- - **`TurnEnded.cache_write` is unused by Cursor's agent** — usage comes from `interaction_update.turn_ended` (`input_tokens`, `output_tokens`, `cache_read`, `cache_write`, `reasoning_tokens`). The provider maps those 1:1 into AI SDK V3 nested usage. Captured live Runs consistently set `cache_write=0` on the wire while still reporting non-zero `cache_read` and `reasoning_tokens`; there is no alternate agent-stream field today that supplies write counts, so OpenCode/Kilo cannot show cache-write tokens for this provider until Cursor populates that counter.
444
+ - **`TurnEnded` usage is whole-Run usage** — on a held multi-step Cursor Run, the final `interaction_update.turn_ended` counters aggregate every model invocation across tool boundaries. OpenCode usage is request-local and uses the latest assistant message to decide compaction, so publishing the aggregate there both double-counts earlier steps and causes premature compaction. The provider therefore uses a current-context estimate for multi-step AI SDK usage and preserves the exact cumulative counters in `providerMetadata.cursor`; single-step Runs still map the counters directly. Cursor also consistently sends `cache_write=0` in captured Runs, so no alternate write count is available.
438
445
  - **`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). 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.
439
446
  - **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.
440
447
  - **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 and future unknown exec variants fail explicitly rather than receiving a guessed response that could deadlock the Run.
@@ -26,15 +26,32 @@ type CompatDetector = () => CompatDetectResult;
26
26
  */
27
27
  export declare function setHostCacheDirOverride(dir: string | undefined): void;
28
28
  export declare function getHostCacheDirOverride(): string | undefined;
29
+ declare const HOST_CACHE_DIRS: readonly ["mimocode", "kilo", "opencode"];
30
+ export type HostCacheDir = (typeof HOST_CACHE_DIRS)[number];
31
+ /**
32
+ * Host cache-dir name inferred from the running binary (`argv[0]` /
33
+ * `process.execPath`). A source-checkout provider can't be located by install
34
+ * path, but the binary that loaded it is authoritative: when this process IS
35
+ * `mimo` / `kilocode`, the cache root is that host's, not OpenCode's. Mirrors
36
+ * OCP `detect()` binaryHint semantics and tolerates the leading-dot Kilo binary
37
+ * (`.kilo`). Only `argv[0]` is inspected — later argv entries are arguments,
38
+ * not binary identity, and could otherwise cause false positives (e.g. an
39
+ * `--opencode-config=…` flag under a fork).
40
+ */
41
+ export declare function hostCacheDirFromProcess(argv?: readonly string[], execPath?: string): HostCacheDir | undefined;
29
42
  /**
30
43
  * Resolve the host cache directory without an override.
31
44
  *
32
45
  * Explicit host environment wins. Otherwise, an installed provider inherits
33
- * the host-named cache containing its module. A source checkout or otherwise
34
- * unidentifiable install defaults to OpenCode. Merely having another host's
35
- * config directory installed is not evidence that it owns this process.
46
+ * the host-named cache containing its module; a source checkout is attributed
47
+ * by the running binary identity (see {@link hostCacheDirFromProcess}). Merely
48
+ * having another host's config directory installed is not evidence that it
49
+ * owns this process.
36
50
  */
37
- export declare function resolveHostCacheDir(env?: HostPathEnv, moduleUrl?: string): string;
51
+ export declare function resolveHostCacheDir(env?: HostPathEnv, moduleUrl?: string, processIdentity?: {
52
+ argv?: readonly string[];
53
+ execPath?: string;
54
+ }): string;
38
55
  /**
39
56
  * Best-effort: if `@opencode-compat/profile` is installed, adopt `detect().profile.paths.cacheDir`
40
57
  * when the host is supported. No-op when OCP is absent or detection fails.
@@ -48,15 +48,52 @@ export function setHostCacheDirOverride(dir) {
48
48
  export function getHostCacheDirOverride() {
49
49
  return hostCacheDirOverride;
50
50
  }
51
+ const HOST_CACHE_DIRS = ["mimocode", "kilo", "opencode"];
52
+ /**
53
+ * Host cache-dir name inferred from the running binary (`argv[0]` /
54
+ * `process.execPath`). A source-checkout provider can't be located by install
55
+ * path, but the binary that loaded it is authoritative: when this process IS
56
+ * `mimo` / `kilocode`, the cache root is that host's, not OpenCode's. Mirrors
57
+ * OCP `detect()` binaryHint semantics and tolerates the leading-dot Kilo binary
58
+ * (`.kilo`). Only `argv[0]` is inspected — later argv entries are arguments,
59
+ * not binary identity, and could otherwise cause false positives (e.g. an
60
+ * `--opencode-config=…` flag under a fork).
61
+ */
62
+ export function hostCacheDirFromProcess(argv = process.argv, execPath = process.execPath) {
63
+ for (const raw of [argv[0], execPath]) {
64
+ if (!raw)
65
+ continue;
66
+ const name = path.basename(String(raw)).toLowerCase().replace(/^\.+/, "");
67
+ if (name === "mimo" ||
68
+ name === "mimocode" ||
69
+ name.startsWith("mimo-") ||
70
+ name.includes("mimocode")) {
71
+ return "mimocode";
72
+ }
73
+ if (name === "kilo" ||
74
+ name === "kilocode" ||
75
+ name.startsWith("kilo-") ||
76
+ name.includes("kilocode")) {
77
+ return "kilo";
78
+ }
79
+ if (name === "opencode" ||
80
+ name.startsWith("opencode-") ||
81
+ name.includes("opencode")) {
82
+ return "opencode";
83
+ }
84
+ }
85
+ return undefined;
86
+ }
51
87
  /**
52
88
  * Resolve the host cache directory without an override.
53
89
  *
54
90
  * Explicit host environment wins. Otherwise, an installed provider inherits
55
- * the host-named cache containing its module. A source checkout or otherwise
56
- * unidentifiable install defaults to OpenCode. Merely having another host's
57
- * config directory installed is not evidence that it owns this process.
91
+ * the host-named cache containing its module; a source checkout is attributed
92
+ * by the running binary identity (see {@link hostCacheDirFromProcess}). Merely
93
+ * having another host's config directory installed is not evidence that it
94
+ * owns this process.
58
95
  */
59
- export function resolveHostCacheDir(env = process.env, moduleUrl = import.meta.url) {
96
+ export function resolveHostCacheDir(env = process.env, moduleUrl = import.meta.url, processIdentity = {}) {
60
97
  const mimoHome = env.MIMOCODE_HOME;
61
98
  if (mimoHome && mimoHome.length > 0) {
62
99
  return path.join(mimoHome, "cache");
@@ -74,12 +111,17 @@ export function resolveHostCacheDir(env = process.env, moduleUrl = import.meta.u
74
111
  modulePath = undefined;
75
112
  }
76
113
  if (modulePath) {
77
- for (const host of ["mimocode", "kilo", "opencode"]) {
114
+ for (const host of HOST_CACHE_DIRS) {
78
115
  const root = path.resolve(cacheHome, host);
79
116
  if (modulePath === root || modulePath.startsWith(`${root}${path.sep}`))
80
117
  return root;
81
118
  }
82
119
  }
120
+ // Source checkout: the module path can't name the host, but the binary that
121
+ // loaded us can (running as `mimo` / `kilocode` / `opencode`).
122
+ const host = hostCacheDirFromProcess(processIdentity.argv ?? process.argv, processIdentity.execPath ?? process.execPath);
123
+ if (host)
124
+ return path.join(cacheHome, host);
83
125
  return path.join(cacheHome, "opencode");
84
126
  }
85
127
  /**
@@ -85,7 +85,7 @@ export declare function buildOpenCodeInteractionGuidance(tools: OpencodeToolDef[
85
85
  export declare function estimateTokens(chars: number): number;
86
86
  /** Estimate current-request prompt tokens from the complete serialized V3 prompt. */
87
87
  export declare function estimatePromptTokens(prompt: LanguageModelV3CallOptions["prompt"]): number;
88
- /** Preserve cumulative Cursor counters as diagnostics, never as AI SDK request usage. */
88
+ /** Preserve exact Cursor counters as diagnostics when they are cumulative across a multi-step Run. */
89
89
  export declare function cursorTurnEndedProviderMetadata(te: Record<string, unknown>): {
90
90
  cursor: {
91
91
  usageVersion: number;
@@ -6,7 +6,7 @@ import { trace, traceRequestContextPaths } from "./debug.js";
6
6
  import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
7
7
  import { decodeFramePayload } from "./protocol/framing.js";
8
8
  import { debugWalkTurnEnded, decodeMessage } from "./protocol/messages.js";
9
- import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, remapEditToolsForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
9
+ import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
10
10
  import { describeCursorExecVariant } from "./protocol/exec-variants.js";
11
11
  import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
12
12
  import { handleKvServerMessage } from "./protocol/kv.js";
@@ -464,13 +464,19 @@ export async function pumpWithRecovery(input) {
464
464
  const delayMs = retryDelayMs(failure, attempt + 1, retryPolicy);
465
465
  trace(`Run retry backoff: attempt=${attempt + 1}/${maxRecoveries} delayMs=${delayMs}`);
466
466
  await sleepForRetry(delayMs, input.abortSignal);
467
- session = await input.recover(checkpoint
467
+ const recovery = checkpoint
468
468
  ? {
469
469
  kind: "resume",
470
470
  conversationId: pumpedSession.conversationId,
471
471
  checkpoint: Uint8Array.from(checkpoint),
472
472
  }
473
- : { kind: "rebase" });
473
+ : { kind: "rebase" };
474
+ session = await input.recover(recovery);
475
+ if (recovery.kind === "resume") {
476
+ session.hadToolCallBoundary = pumpedSession.hadToolCallBoundary;
477
+ session.usageEstimate = { ...pumpedSession.usageEstimate };
478
+ session.editToolCalls = new Map(pumpedSession.editToolCalls);
479
+ }
474
480
  input.onSession?.(session);
475
481
  }
476
482
  finally {
@@ -693,6 +699,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
693
699
  frames: stream.frames()[Symbol.asyncIterator](),
694
700
  pending: new Map(),
695
701
  displayToolCalls: new Map(),
702
+ editToolCalls: new Map(),
696
703
  nextBridgedExecId: 900_000,
697
704
  blobs: new Map(),
698
705
  toolDescriptors,
@@ -1145,8 +1152,12 @@ export async function pump(session, controller, ids, abortSignal) {
1145
1152
  };
1146
1153
  const emitFinish = (te, reason) => {
1147
1154
  closeOpenSpans();
1148
- if (te) {
1149
- // Authoritative TurnEnded counts — replace the running estimate.
1155
+ // TurnEnded is request-local only when this Run did not previously stop at
1156
+ // a tool boundary. On a held multi-step Run Cursor aggregates every model
1157
+ // invocation into these counters; treating that billed total as the final
1158
+ // request's prompt size makes OpenCode compact after ordinary tasks.
1159
+ const cumulativeTurnEnded = !!te && session.hadToolCallBoundary === true;
1160
+ if (te && !cumulativeTurnEnded) {
1150
1161
  session.usageEstimate = {
1151
1162
  inputTokens: turnEndedCounter(te, "input_tokens"),
1152
1163
  outputTokens: turnEndedCounter(te, "output_tokens"),
@@ -1156,9 +1167,11 @@ export async function pump(session, controller, ids, abortSignal) {
1156
1167
  };
1157
1168
  }
1158
1169
  const est = session.usageEstimate;
1159
- const usage = te
1170
+ const usage = te && !cumulativeTurnEnded
1160
1171
  ? buildLanguageModelV3UsageFromTurnEnded(te)
1161
1172
  : buildLanguageModelV3UsageFromEstimate(est, promptTokens, requestUsage.outputChars, estimateTokens);
1173
+ if (!te)
1174
+ session.hadToolCallBoundary = true;
1162
1175
  const providerMetadata = te ? cursorTurnEndedProviderMetadata(te) : undefined;
1163
1176
  const reasonLabel = typeof reason === "object" && reason && "unified" in reason
1164
1177
  ? String(reason.unified ?? "unknown")
@@ -1169,9 +1182,11 @@ export async function pump(session, controller, ids, abortSignal) {
1169
1182
  `v3In=${inTotal} v3Out=${outTotal} ` +
1170
1183
  `v3CacheRead=${usage.inputTokens?.cacheRead ?? 0} v3CacheWrite=${usage.inputTokens?.cacheWrite ?? 0} ` +
1171
1184
  `v3Reasoning=${usage.outputTokens?.reasoning ?? 0} ` +
1172
- `rawIn=${est.inputTokens} rawOut=${est.outputTokens} ` +
1173
- `rawCacheRead=${est.cacheRead} rawCacheWrite=${est.cacheWrite} ` +
1174
- `source=${te ? "turn_ended" : "estimate"}`);
1185
+ `rawIn=${te ? turnEndedCounter(te, "input_tokens") : est.inputTokens} ` +
1186
+ `rawOut=${te ? turnEndedCounter(te, "output_tokens") : est.outputTokens} ` +
1187
+ `rawCacheRead=${te ? turnEndedCounter(te, "cache_read") : est.cacheRead} ` +
1188
+ `rawCacheWrite=${te ? turnEndedCounter(te, "cache_write") : est.cacheWrite} ` +
1189
+ `source=${te ? (cumulativeTurnEnded ? "estimate-cumulative-turn-ended" : "turn_ended") : "estimate"}`);
1175
1190
  safeEnqueue({
1176
1191
  type: "finish",
1177
1192
  usage,
@@ -1327,6 +1342,15 @@ export async function pump(session, controller, ids, abortSignal) {
1327
1342
  if (callId && toolCall) {
1328
1343
  session.displayToolCalls.set(callId, toolCall);
1329
1344
  const variant = Object.keys(toolCall).find((k) => k.endsWith("_tool_call")) ?? "?";
1345
+ const display = parseDisplayToolCall(callId, toolCall);
1346
+ const editPath = display?.variant === "edit_tool_call"
1347
+ && typeof display.args.path === "string"
1348
+ ? display.args.path
1349
+ : undefined;
1350
+ if (editPath) {
1351
+ const editToolCalls = session.editToolCalls ?? (session.editToolCalls = new Map());
1352
+ editToolCalls.set(callId, { path: editPath });
1353
+ }
1330
1354
  const callIdLog = callId.replace(/\r?\n/g, "\\n");
1331
1355
  let wireFields = "";
1332
1356
  if (variant === "?") {
@@ -1341,6 +1365,8 @@ export async function pump(session, controller, ids, abortSignal) {
1341
1365
  else if (iu?.tool_call_completed) {
1342
1366
  const completed = iu.tool_call_completed;
1343
1367
  const callId = typeof completed.call_id === "string" ? completed.call_id : "";
1368
+ if (callId)
1369
+ session.editToolCalls?.delete(callId);
1344
1370
  // If exec already claimed this call_id, display map entry is gone — skip.
1345
1371
  if (!callId || !session.displayToolCalls.has(callId)) {
1346
1372
  if (callId) {
@@ -1436,6 +1462,7 @@ export async function pump(session, controller, ids, abortSignal) {
1436
1462
  }
1437
1463
  else {
1438
1464
  replaySafety.markBarrier("non-control-exec");
1465
+ const displayCallId = extractExecDisplayCallId(esm);
1439
1466
  const parsed = parseExecServerMessage(esm);
1440
1467
  if (parsed) {
1441
1468
  const executableToolName = resolveCustomWebToolAlias(parsed.toolName, session.toolAliases);
@@ -1444,12 +1471,24 @@ export async function pump(session, controller, ids, abortSignal) {
1444
1471
  parsed.toolName = executableToolName;
1445
1472
  }
1446
1473
  remapNativeSubagentForCatalog(parsed, advertisedToolNameSet, session.subagentCatalog);
1474
+ const editCall = displayCallId
1475
+ ? session.editToolCalls?.get(displayCallId)
1476
+ : undefined;
1477
+ if (editCall) {
1478
+ const remapped = remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNameSet, editCall.path, workspaceRootFromRequestContext(session.requestContext));
1479
+ if (parsed.resultField === "write_result" && displayCallId) {
1480
+ session.editToolCalls?.delete(displayCallId);
1481
+ }
1482
+ if (remapped) {
1483
+ trace(`exec: preserved edit intent callId=${displayCallId} ` +
1484
+ `path=${JSON.stringify(editCall.path)}`);
1485
+ }
1486
+ }
1447
1487
  // Hosts that advertise `apply_patch` in place of `edit`/`write` (see
1448
1488
  // protocol/apply-patch.ts) still receive Cursor's native write/edit
1449
1489
  // exec requests. Translate before the unavailable-tool check below.
1450
1490
  remapEditToolsForCatalog(parsed, advertisedToolNameSet, workspaceRootFromRequestContext(session.requestContext));
1451
1491
  }
1452
- const displayCallId = extractExecDisplayCallId(esm);
1453
1492
  trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
1454
1493
  if (parsed) {
1455
1494
  if (parsed.localError) {
@@ -1770,7 +1809,7 @@ export function estimatePromptTokens(prompt) {
1770
1809
  .join("\n");
1771
1810
  return estimateTokens(serializedContent.length);
1772
1811
  }
1773
- /** Preserve cumulative Cursor counters as diagnostics, never as AI SDK request usage. */
1812
+ /** Preserve exact Cursor counters as diagnostics when they are cumulative across a multi-step Run. */
1774
1813
  export function cursorTurnEndedProviderMetadata(te) {
1775
1814
  return {
1776
1815
  cursor: {
@@ -87,6 +87,14 @@ export declare function resolveCursorSubagentType(subagentType: string, catalog?
87
87
  export declare function mapCursorSubagentTypeToOpenCode(subagentType: string): string;
88
88
  /** Resolve a native Cursor subagent request to this turn's executor and catalog. */
89
89
  export declare function remapNativeSubagentForCatalog(parsed: ParsedExecRequest, advertisedToolNames: Iterable<string>, catalog?: HostSubagentCatalog): void;
90
+ /**
91
+ * Preserve a legacy Cursor edit's intent when its internal executor follows
92
+ * `edit_tool_call` with a whole-file `write_args` request.
93
+ *
94
+ * The result field intentionally remains `write_result`: that is the response
95
+ * Cursor is awaiting even though OpenCode executes the mutation via `edit`.
96
+ */
97
+ export declare function remapCorrelatedEditWriteForCatalog(parsed: ParsedExecRequest, advertisedToolNames: Iterable<string>, editPath: string, workspaceRoot?: string): boolean;
90
98
  /**
91
99
  * Express a native Cursor write/edit as `apply_patch` when the host swapped the
92
100
  * edit-tool family out from under us.
@@ -483,6 +483,125 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
483
483
  }
484
484
  /** Matches Cursor's own pre-write read threshold (`local-exec` 52428800). */
485
485
  const MAX_EDIT_SOURCE_BYTES = 50 * 1024 * 1024;
486
+ /** Return the number of non-overlapping occurrences, stopping after ambiguity. */
487
+ function replacementOccurrenceCount(source, needle) {
488
+ if (!needle)
489
+ return 0;
490
+ const first = source.indexOf(needle);
491
+ if (first === -1)
492
+ return 0;
493
+ return source.indexOf(needle, first + needle.length) === -1 ? 1 : 2;
494
+ }
495
+ function previousLineStart(source, start) {
496
+ if (start <= 0)
497
+ return 0;
498
+ const before = source[start - 1] === "\n" ? start - 2 : start - 1;
499
+ return source.lastIndexOf("\n", before) + 1;
500
+ }
501
+ function followingLineEnd(source, end) {
502
+ if (end >= source.length)
503
+ return source.length;
504
+ const newline = source.indexOf("\n", end);
505
+ return newline === -1 ? source.length : newline + 1;
506
+ }
507
+ /**
508
+ * Collapse a whole-file replacement into one unique, line-bounded substring
509
+ * edit. Cursor's legacy edit executor computes a complete new file internally;
510
+ * OpenCode's `edit` tool instead wants old/new substrings.
511
+ */
512
+ function planWholeFileEdit(source, target) {
513
+ if (!source || source === target)
514
+ return undefined;
515
+ let prefix = 0;
516
+ const shared = Math.min(source.length, target.length);
517
+ while (prefix < shared && source[prefix] === target[prefix])
518
+ prefix++;
519
+ let suffix = 0;
520
+ while (suffix < source.length - prefix
521
+ && suffix < target.length - prefix
522
+ && source[source.length - suffix - 1] === target[target.length - suffix - 1])
523
+ suffix++;
524
+ const sourceChangeEnd = source.length - suffix;
525
+ const targetChangeEnd = target.length - suffix;
526
+ let start = prefix === 0 ? 0 : source.lastIndexOf("\n", prefix - 1) + 1;
527
+ let end = sourceChangeEnd >= source.length
528
+ ? source.length
529
+ : followingLineEnd(source, sourceChangeEnd);
530
+ // A pure insertion can initially select no source text. Include an adjacent
531
+ // line because OpenCode edit deliberately rejects an empty oldString.
532
+ if (start === end) {
533
+ if (start > 0)
534
+ start = previousLineStart(source, start);
535
+ else
536
+ end = followingLineEnd(source, end);
537
+ }
538
+ const replacement = () => ({
539
+ oldString: source.slice(start, end),
540
+ newString: source.slice(start, prefix)
541
+ + target.slice(prefix, targetChangeEnd)
542
+ + source.slice(sourceChangeEnd, end),
543
+ });
544
+ let result = replacement();
545
+ while (replacementOccurrenceCount(source, result.oldString) !== 1) {
546
+ const priorStart = start;
547
+ const priorEnd = end;
548
+ if (start > 0)
549
+ start = previousLineStart(source, start);
550
+ if (end < source.length)
551
+ end = followingLineEnd(source, end);
552
+ if (start === priorStart && end === priorEnd)
553
+ return undefined;
554
+ result = replacement();
555
+ }
556
+ return result;
557
+ }
558
+ /**
559
+ * Preserve a legacy Cursor edit's intent when its internal executor follows
560
+ * `edit_tool_call` with a whole-file `write_args` request.
561
+ *
562
+ * The result field intentionally remains `write_result`: that is the response
563
+ * Cursor is awaiting even though OpenCode executes the mutation via `edit`.
564
+ */
565
+ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames, editPath, workspaceRoot) {
566
+ if (parsed.resultField !== "write_result" || parsed.toolName !== "write")
567
+ return false;
568
+ const advertised = new Set(advertisedToolNames);
569
+ if (!advertised.has("edit") && !advertised.has(APPLY_PATCH_TOOL))
570
+ return false;
571
+ const filePath = str(parsed.args.filePath);
572
+ const content = stringValue(parsed.args.content);
573
+ if (!filePath || content === undefined || !editPath)
574
+ return false;
575
+ const root = workspaceRoot ?? process.cwd();
576
+ const absolute = path.resolve(root, filePath);
577
+ if (absolute !== path.resolve(root, editPath))
578
+ return false;
579
+ let source;
580
+ try {
581
+ const stat = fs.statSync(absolute);
582
+ if (!stat.isFile() || stat.size > MAX_EDIT_SOURCE_BYTES)
583
+ return false;
584
+ if (Buffer.byteLength(content, "utf8") > MAX_EDIT_SOURCE_BYTES)
585
+ return false;
586
+ source = fs.readFileSync(absolute, "utf8");
587
+ }
588
+ catch {
589
+ // Missing targets are creations and must stay writes. Other read failures
590
+ // also fall back to the original host permission path without guessing.
591
+ return false;
592
+ }
593
+ const replacement = planWholeFileEdit(source, content);
594
+ if (!replacement)
595
+ return false;
596
+ parsed.toolName = "edit";
597
+ parsed.args = {
598
+ filePath,
599
+ oldString: replacement.oldString,
600
+ newString: replacement.newString,
601
+ };
602
+ parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
603
+ return true;
604
+ }
486
605
  /**
487
606
  * Express a native Cursor write/edit as `apply_patch` when the host swapped the
488
607
  * edit-tool family out from under us.
@@ -1202,9 +1321,9 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1202
1321
  // Cursor's native read_args lands here; `mcp_args` reads land in
1203
1322
  // mcp_result. Live captures show gpt-5.4-mini and grok-4.5 both use the
1204
1323
  // native channel, so this is the path that matters in practice.
1205
- const content = unwrapReadOutput(output);
1206
1324
  const statPath = extractPathTag(output) ?? readPath;
1207
1325
  const outputMetadata = parseOpenCodeReadMetadata(output);
1326
+ const content = restoreCompleteReadTerminator(unwrapReadOutput(output), statPath, outputMetadata, resultMetadata, resultRoot);
1208
1327
  const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
1209
1328
  const rangeApplied = readRangeApplied(resultMetadata, totalLines);
1210
1329
  // `truncated` alone is not enough: it is set here, and models still assert
@@ -1452,6 +1571,53 @@ function parseOpenCodeReadMetadata(output) {
1452
1571
  return { totalLines: Number(complete[1]) };
1453
1572
  return {};
1454
1573
  }
1574
+ /**
1575
+ * OpenCode's numbered read envelope cannot represent the terminator after the
1576
+ * final line. Cursor's legacy edit executor performs exact replacement against
1577
+ * this content, so dropping a real LF/CRLF makes otherwise-valid edits fail and
1578
+ * encourages a whole-file rewrite. Restore only that final terminator for an
1579
+ * unbounded, confirmed-complete read; paged/capped reads stay untouched.
1580
+ */
1581
+ function restoreCompleteReadTerminator(content, readPath, metadata, resultMetadata, workspaceRoot) {
1582
+ if (!readPath
1583
+ || metadata.totalLines === undefined
1584
+ || metadata.startLine !== undefined
1585
+ || metadata.endLine !== undefined
1586
+ || num(resultMetadata?.offset) !== undefined
1587
+ || num(resultMetadata?.limit) !== undefined
1588
+ || content.endsWith("\n"))
1589
+ return content;
1590
+ const absolute = path.isAbsolute(readPath)
1591
+ ? readPath
1592
+ : path.resolve(workspaceRoot ?? process.cwd(), readPath);
1593
+ let fd;
1594
+ try {
1595
+ fd = fs.openSync(absolute, "r");
1596
+ const size = fs.fstatSync(fd).size;
1597
+ if (size === 0)
1598
+ return content;
1599
+ const tail = Buffer.alloc(Math.min(2, size));
1600
+ fs.readSync(fd, tail, 0, tail.length, size - tail.length);
1601
+ if (tail.length >= 2 && tail[tail.length - 2] === 0x0d && tail[tail.length - 1] === 0x0a) {
1602
+ return `${content}\r\n`;
1603
+ }
1604
+ if (tail[tail.length - 1] === 0x0a)
1605
+ return `${content}\n`;
1606
+ }
1607
+ catch {
1608
+ // The host read result remains authoritative when the local path cannot be
1609
+ // inspected (deleted/raced/permission changed after the tool completed).
1610
+ }
1611
+ finally {
1612
+ if (fd !== undefined) {
1613
+ try {
1614
+ fs.closeSync(fd);
1615
+ }
1616
+ catch { /* already closed */ }
1617
+ }
1618
+ }
1619
+ return content;
1620
+ }
1455
1621
  /**
1456
1622
  * OpenCode's `read` caps output at 50 KB (`tool/read.ts` MAX_BYTES = 50 * 1024)
1457
1623
  * and cuts on a whole-line boundary, then appends
package/dist/session.d.ts CHANGED
@@ -112,6 +112,14 @@ export type CursorSession = {
112
112
  * or when we bridge the completed display call into an OpenCode tool-call.
113
113
  */
114
114
  displayToolCalls: Map<string, Record<string, unknown>>;
115
+ /**
116
+ * Legacy edit calls whose authoritative exec path is still in progress.
117
+ * Cursor implements these as read -> whole-file write; retaining the path
118
+ * lets the pump expose the final mutation to OpenCode as a targeted edit.
119
+ */
120
+ editToolCalls?: Map<string, {
121
+ path: string;
122
+ }>;
115
123
  /** Monotonic synthetic exec ids for bridged (display-only) OpenCode tool calls. */
116
124
  nextBridgedExecId: number;
117
125
  /** KV blob store: blob_id (hex) → data, for Cursor's out-of-band payload channel. */
@@ -132,8 +140,8 @@ export type CursorSession = {
132
140
  allowTools: boolean;
133
141
  /**
134
142
  * Best-effort token usage for this held-open Run. Updated from text/tool
135
- * activity and replaced by TurnEnded when the turn completes. Emitted on
136
- * tool-calls finishes so OpenCode does not store all-zero usage mid-loop.
143
+ * activity and replaced by TurnEnded only for a single-step Run. Emitted on
144
+ * tool-call finishes so OpenCode does not store all-zero usage mid-loop.
137
145
  */
138
146
  usageEstimate: {
139
147
  inputTokens: number;
@@ -142,6 +150,13 @@ export type CursorSession = {
142
150
  cacheWrite: number;
143
151
  reasoningTokens: number;
144
152
  };
153
+ /**
154
+ * True after this held Run has already crossed an OpenCode tool-call
155
+ * boundary. Cursor's eventual TurnEnded counters then cover the whole Run,
156
+ * not only the final doStream request, so they cannot be used as that
157
+ * request's context usage.
158
+ */
159
+ hadToolCallBoundary?: boolean;
145
160
  /**
146
161
  * True while a doStream pull() is actively reading this session's frames.
147
162
  * Prevents a late cancel/abort from a prior ReadableStream from destroying
package/dist/usage.d.ts CHANGED
@@ -2,8 +2,9 @@ import type { LanguageModelV3Usage } from "@ai-sdk/provider";
2
2
  /** Non-negative integer counter from a Cursor `turn_ended` field. */
3
3
  export declare function turnEndedCounter(te: Record<string, unknown>, key: string): number;
4
4
  /**
5
- * Map authoritative Cursor TurnEnded counters to AI SDK V3 usage (Kilo/OpenCode
6
- * flatten via input total = noCache + cacheRead + cacheWrite).
5
+ * Map request-local Cursor TurnEnded counters to AI SDK V3 usage (Kilo/OpenCode
6
+ * flatten via input total = noCache + cacheRead + cacheWrite). Only use this
7
+ * for a Run with no prior tool boundary; multi-step TurnEnded is cumulative.
7
8
  *
8
9
  * Contract: `input_tokens` is non-cached input; cache fields are separate.
9
10
  * Cursor's agent wire currently sends `cache_write=0` even when later turns
@@ -18,7 +19,15 @@ export type CursorUsageEstimate = {
18
19
  cacheWrite: number;
19
20
  reasoningTokens: number;
20
21
  };
21
- /** Mid-turn finish (e.g. tool-calls) before `turn_ended` — keep non-zero estimates. */
22
+ /**
23
+ * Request-local usage for a held Cursor Run.
24
+ *
25
+ * `est.inputTokens` tracks the seed plus delivered tool results, while
26
+ * `est.outputTokens` accumulates model output across earlier tool boundaries.
27
+ * Move prior output into the current input estimate and report only this
28
+ * doStream request's output. Cache counters are intentionally absent: the
29
+ * only counters Cursor exposes at TurnEnded are cumulative for the whole Run.
30
+ */
22
31
  export declare function buildLanguageModelV3UsageFromEstimate(est: CursorUsageEstimate, promptTokens: number, outputChars: number, estimateCharsToTokens: (chars: number) => number): LanguageModelV3Usage;
23
32
  /** Mirror Kilo `Session.getUsage` / AI SDK flat fields from V3 nested usage. */
24
33
  export declare function kiloShapedUsageFromV3(usage: LanguageModelV3Usage): {
package/dist/usage.js CHANGED
@@ -6,8 +6,9 @@ export function turnEndedCounter(te, key) {
6
6
  : 0;
7
7
  }
8
8
  /**
9
- * Map authoritative Cursor TurnEnded counters to AI SDK V3 usage (Kilo/OpenCode
10
- * flatten via input total = noCache + cacheRead + cacheWrite).
9
+ * Map request-local Cursor TurnEnded counters to AI SDK V3 usage (Kilo/OpenCode
10
+ * flatten via input total = noCache + cacheRead + cacheWrite). Only use this
11
+ * for a Run with no prior tool boundary; multi-step TurnEnded is cumulative.
11
12
  *
12
13
  * Contract: `input_tokens` is non-cached input; cache fields are separate.
13
14
  * Cursor's agent wire currently sends `cache_write=0` even when later turns
@@ -34,27 +35,30 @@ export function buildLanguageModelV3UsageFromTurnEnded(te) {
34
35
  },
35
36
  };
36
37
  }
37
- /** Mid-turn finish (e.g. tool-calls) before `turn_ended` — keep non-zero estimates. */
38
+ /**
39
+ * Request-local usage for a held Cursor Run.
40
+ *
41
+ * `est.inputTokens` tracks the seed plus delivered tool results, while
42
+ * `est.outputTokens` accumulates model output across earlier tool boundaries.
43
+ * Move prior output into the current input estimate and report only this
44
+ * doStream request's output. Cache counters are intentionally absent: the
45
+ * only counters Cursor exposes at TurnEnded are cumulative for the whole Run.
46
+ */
38
47
  export function buildLanguageModelV3UsageFromEstimate(est, promptTokens, outputChars, estimateCharsToTokens) {
39
- const hasInputBreakdown = est.cacheRead > 0 || est.cacheWrite > 0 || est.inputTokens > 0;
40
- const inputNoCache = hasInputBreakdown ? est.inputTokens : promptTokens;
41
- const cacheRead = est.cacheRead;
42
- const cacheWrite = est.cacheWrite;
43
- const outputFromChars = estimateCharsToTokens(outputChars);
44
- const outputText = est.outputTokens > 0 ? est.outputTokens : outputFromChars;
45
- const reasoning = est.reasoningTokens;
46
- const outputTotal = outputText + reasoning;
48
+ const outputText = estimateCharsToTokens(outputChars);
49
+ const priorOutput = Math.max(0, est.outputTokens - outputText);
50
+ const inputNoCache = Math.max(promptTokens, est.inputTokens + priorOutput);
47
51
  return {
48
52
  inputTokens: {
49
- total: inputNoCache + cacheRead + cacheWrite,
53
+ total: inputNoCache,
50
54
  noCache: inputNoCache,
51
- cacheRead,
52
- cacheWrite,
55
+ cacheRead: 0,
56
+ cacheWrite: 0,
53
57
  },
54
58
  outputTokens: {
55
- total: outputTotal,
59
+ total: outputText,
56
60
  text: outputText,
57
- reasoning: reasoning > 0 ? reasoning : undefined,
61
+ reasoning: undefined,
58
62
  },
59
63
  };
60
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.4.0",
3
+ "version": "0.4.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",