cursor-opencode-provider 0.4.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +33 -23
  2. package/dist/context/agents.d.ts +1 -1
  3. package/dist/context/agents.js +1 -1
  4. package/dist/context/auth-store.js +2 -2
  5. package/dist/context/build.d.ts +11 -1
  6. package/dist/context/build.js +76 -25
  7. package/dist/context/frozen.d.ts +29 -0
  8. package/dist/context/frozen.js +168 -0
  9. package/dist/context/index.d.ts +3 -2
  10. package/dist/context/index.js +3 -2
  11. package/dist/context/paths.d.ts +34 -53
  12. package/dist/context/paths.js +54 -118
  13. package/dist/context/rules.d.ts +1 -0
  14. package/dist/context/rules.js +2 -2
  15. package/dist/image-save-tool.d.ts +19 -0
  16. package/dist/image-save-tool.js +25 -0
  17. package/dist/image-save.d.ts +58 -0
  18. package/dist/image-save.js +189 -0
  19. package/dist/image-staging.d.ts +59 -0
  20. package/dist/image-staging.js +91 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/language-model.d.ts +13 -11
  23. package/dist/language-model.js +957 -150
  24. package/dist/plan-execution-kickoff.d.ts +55 -0
  25. package/dist/plan-execution-kickoff.js +162 -0
  26. package/dist/plugin-opencode2.js +52 -3
  27. package/dist/plugin-v2.d.ts +11 -9
  28. package/dist/plugin-v2.js +6 -13
  29. package/dist/plugin.d.ts +9 -0
  30. package/dist/plugin.js +61 -5
  31. package/dist/pricing-data.d.ts +16 -4
  32. package/dist/pricing-data.js +16 -4
  33. package/dist/protocol/ask-question.d.ts +133 -0
  34. package/dist/protocol/ask-question.js +320 -0
  35. package/dist/protocol/blob-reachability.d.ts +15 -0
  36. package/dist/protocol/blob-reachability.js +140 -0
  37. package/dist/protocol/blob-store.d.ts +37 -0
  38. package/dist/protocol/blob-store.js +115 -0
  39. package/dist/protocol/conversation-bind.d.ts +13 -0
  40. package/dist/protocol/conversation-bind.js +27 -1
  41. package/dist/protocol/conversation-persistence.d.ts +32 -0
  42. package/dist/protocol/conversation-persistence.js +504 -0
  43. package/dist/protocol/conversation-state.d.ts +16 -0
  44. package/dist/protocol/conversation-state.js +62 -0
  45. package/dist/protocol/create-plan.d.ts +141 -0
  46. package/dist/protocol/create-plan.js +373 -0
  47. package/dist/protocol/generate-image.d.ts +54 -0
  48. package/dist/protocol/generate-image.js +101 -0
  49. package/dist/protocol/interactions.d.ts +110 -8
  50. package/dist/protocol/interactions.js +295 -35
  51. package/dist/protocol/kv.d.ts +1 -0
  52. package/dist/protocol/kv.js +2 -0
  53. package/dist/protocol/messages.d.ts +2 -0
  54. package/dist/protocol/messages.js +148 -8
  55. package/dist/protocol/request.d.ts +2 -0
  56. package/dist/protocol/request.js +2 -0
  57. package/dist/protocol/switch-mode.d.ts +178 -0
  58. package/dist/protocol/switch-mode.js +417 -0
  59. package/dist/protocol/token-details.d.ts +33 -0
  60. package/dist/protocol/token-details.js +155 -0
  61. package/dist/protocol/tool-call-bridge.js +10 -5
  62. package/dist/protocol/tools.d.ts +44 -11
  63. package/dist/protocol/tools.js +171 -41
  64. package/dist/session.d.ts +39 -11
  65. package/dist/shared.d.ts +3 -0
  66. package/dist/shared.js +3 -0
  67. package/dist/usage.d.ts +42 -37
  68. package/dist/usage.js +201 -70
  69. package/dist/web-search-tool.d.ts +22 -26
  70. package/dist/web-search-tool.js +25 -15
  71. package/package.json +7 -4
@@ -71,9 +71,9 @@ export type HostSubagentDefinition = {
71
71
  description?: string;
72
72
  };
73
73
  export type HostSubagentCatalog = {
74
- executor?: "task" | "actor";
74
+ executor?: "task";
75
75
  agents: HostSubagentDefinition[];
76
- /** True when the host tool supplied its complete, permission-filtered catalog. */
76
+ /** True when the OpenCode task tool supplied its complete, permission-filtered catalog. */
77
77
  complete: boolean;
78
78
  };
79
79
  /** Extract the current host's permission-filtered Task/Actor recipient catalog. */
@@ -89,6 +89,16 @@ export declare function resolveCursorSubagentType(subagentType: string, catalog?
89
89
  export declare function mapCursorSubagentTypeToOpenCode(subagentType: string): string;
90
90
  /** Resolve a native Cursor subagent request to this turn's executor and catalog. */
91
91
  export declare function remapNativeSubagentForCatalog(parsed: ParsedExecRequest, advertisedToolNames: Iterable<string>, catalog?: HostSubagentCatalog): void;
92
+ /**
93
+ * Complete the private read inside Cursor's legacy edit transaction without
94
+ * routing it through OpenCode's user-facing `read` tool. The latter caps every
95
+ * result at 50 KB, while Cursor's editor needs the complete file to calculate
96
+ * the correlated whole-file `write_args` that follows.
97
+ *
98
+ * The caller is responsible for proving that `filePath` is the same regular
99
+ * file named by an active `edit_tool_call` and is contained by the workspace.
100
+ */
101
+ export declare function buildCompleteEditReadMessages(execId: number, sourcePath: string, resultPath?: string): Uint8Array[] | undefined;
92
102
  /**
93
103
  * Preserve a legacy Cursor edit's intent when its internal executor follows
94
104
  * `edit_tool_call` with a whole-file `write_args` request.
@@ -125,6 +135,13 @@ export type ParsedExecRequest = {
125
135
  /** Original request values needed by typed Cursor result messages. */
126
136
  resultMetadata?: Record<string, unknown>;
127
137
  };
138
+ /**
139
+ * A partial-read notice is for Cursor's model, never for a file. Refuse only
140
+ * whole-file mutation forms that echo it. A targeted edit or Update File patch
141
+ * cannot truncate an unseen tail, and may legitimately edit source that quotes
142
+ * this provider's warning text.
143
+ */
144
+ export declare function rejectPartialReadMutation(parsed: ParsedExecRequest): void;
128
145
  export declare function parseExecServerMessage(msg: Record<string, unknown>): ParsedExecRequest | undefined;
129
146
  /**
130
147
  * Remap Cursor exec / MCP arg shapes onto OpenCode's Effect Schema keys.
@@ -134,7 +151,28 @@ export declare function parseExecServerMessage(msg: Record<string, unknown>): Pa
134
151
  export declare function mapCursorArgsToOpencode(toolName: string, raw: Record<string, unknown>, execVariant?: string): {
135
152
  toolName: string;
136
153
  args: Record<string, unknown>;
154
+ binaryBytes?: Uint8Array;
137
155
  };
156
+ /**
157
+ * Decode `WriteArgs.file_bytes`. `encoding_hint` names the file's original
158
+ * encoding; OpenCode's `write` takes a string, so an unknown encoding label
159
+ * falls back to UTF-8 rather than dropping the write.
160
+ *
161
+ * Returns undefined when the bytes are not text in that encoding. A non-fatal
162
+ * decode would replace every undecodable sequence with U+FFFD and hand the host
163
+ * a corrupted file that still reports success, so binary content must take the
164
+ * byte-preserving path instead (see `isBinaryWriteRequest`).
165
+ */
166
+ export declare function decodeWriteBytes(bytes: Uint8Array, encodingHint?: string): string | undefined;
167
+ /**
168
+ * True when a parsed `write` request carries bytes that are not text. Such a
169
+ * request cannot go to OpenCode's `write` tool at all — it takes a string, and
170
+ * additionally BOM-splits, diffs, and runs `Format.file()` on what it writes.
171
+ */
172
+ export declare function binaryWritePayload(parsed: ParsedExecRequest): {
173
+ path: string;
174
+ data: Uint8Array;
175
+ } | undefined;
138
176
  /**
139
177
  * Map Cursor McpArgs back to the OpenCode tool id.
140
178
  * Prefers provider_identifier + bare tool_name (github + create_pull_request
@@ -145,15 +183,10 @@ export declare function mcpRealToolName(mcpArgs: Record<string, unknown>, defaul
145
183
  * A read target addressed by URI scheme rather than by local filesystem path.
146
184
  *
147
185
  * Cursor's LocalReadExecutor only ever reads local files, so this provider
148
- * mirrors its pre-execution validation. Host read tools are not so limited:
149
- * oh-my-pi's `read` accepts "a path, URL, or internal URI", and mounts every
150
- * discoverable tool — including all MCP server tools — under `xd://<tool>`
151
- * device URLs that are read for their schema and written to for execution
152
- * (`tools.xdev`, on by default). Resolving those against the workspace root
153
- * mangles `xd://everything_echo` into `<root>/xd:/everything_echo`, which stats
154
- * as missing and gets refused as `file_not_found` — so the host's read tool is
155
- * never called and every xd://-mounted MCP tool is unreachable through this
156
- * provider. Such targets must be forwarded untouched and left to the host.
186
+ * mirrors its pre-execution validation. An advertised host read tool may also
187
+ * accept URLs or internal URI schemes. Resolving those against the workspace
188
+ * root mangles the URI and produces a false `file_not_found`, so URI targets
189
+ * are forwarded untouched and left to the advertised executor.
157
190
  *
158
191
  * Requires a scheme of two or more characters followed by `://`, so Windows
159
192
  * drive letters (`C:\src`, `C:/src`) stay local paths.
@@ -299,6 +299,10 @@ const PRESERVE_EMPTY_STRING_KEYS = new Set([
299
299
  "file_text",
300
300
  "fileText",
301
301
  "stream_content",
302
+ // Some advertised edit schemas use a single textual `input` payload. Preserve
303
+ // an empty value so the executor reports the real edit error rather than a
304
+ // misleading missing-property error.
305
+ "input",
302
306
  "oldString",
303
307
  "old_string",
304
308
  "newString",
@@ -396,18 +400,16 @@ function subagentTypeEnumValues(schema) {
396
400
  }
397
401
  /** Extract the current host's permission-filtered Task/Actor recipient catalog. */
398
402
  export function extractHostSubagentCatalog(tools) {
399
- const executorTool = tools.find((tool) => tool.name === "actor") ??
400
- tools.find((tool) => tool.name === "task");
403
+ const executorTool = tools.find((tool) => tool.name === "task");
401
404
  if (!executorTool)
402
405
  return { agents: [], complete: true };
403
406
  const described = parseSubagentDescriptionCatalog(executorTool.description);
404
407
  const enumNames = subagentTypeEnumValues(executorTool.inputSchema);
405
408
  const descriptions = new Map(described.agents.map((agent) => [agent.name, agent.description]));
406
- // MiMo's Actor enum is stricter than its prose catalog (`mode: subagent`
407
- // rather than every non-primary agent), so structured names are authoritative.
409
+ // A structured subagent_type enum is stricter than prose, so it is authoritative.
408
410
  const names = new Set(enumNames.length > 0 ? enumNames : described.agents.map((agent) => agent.name));
409
411
  return {
410
- executor: executorTool.name,
412
+ executor: "task",
411
413
  agents: [...names].map((name) => ({
412
414
  name,
413
415
  ...(descriptions.get(name) ? { description: descriptions.get(name) } : {}),
@@ -467,9 +469,9 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
467
469
  if (parsed.resultField !== "subagent_result" || parsed.toolName !== "task")
468
470
  return;
469
471
  const advertised = new Set(advertisedToolNames);
470
- const executor = advertised.has("actor") ? "actor" : advertised.has("task") ? "task" : undefined;
471
- if (!executor)
472
+ if (!advertised.has("task"))
472
473
  return;
474
+ const executor = "task";
473
475
  const description = str(parsed.args.description) ?? "";
474
476
  const prompt = str(parsed.args.prompt) ?? "";
475
477
  const cursorSubagentType = str(parsed.resultMetadata?.cursor_subagent_type) ??
@@ -484,28 +486,55 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
484
486
  }
485
487
  const resumeAgentId = str(parsed.args.task_id);
486
488
  parsed.toolName = executor;
487
- if (executor === "task") {
488
- parsed.args = {
489
- description,
490
- prompt,
491
- subagent_type: subagentType,
492
- ...(resumeAgentId ? { task_id: resumeAgentId } : {}),
493
- ...(parsed.args.background === true ? { background: true } : {}),
494
- };
495
- return;
496
- }
497
489
  parsed.args = {
498
- operation: {
499
- action: parsed.args.background === true ? "spawn" : "run",
500
- description,
501
- prompt,
502
- subagent_type: subagentType,
503
- ...(resumeAgentId ? { actor_id: resumeAgentId } : {}),
504
- },
490
+ description,
491
+ prompt,
492
+ subagent_type: subagentType,
493
+ ...(resumeAgentId ? { task_id: resumeAgentId } : {}),
494
+ ...(parsed.args.background === true ? { background: true } : {}),
505
495
  };
506
496
  }
507
497
  /** Matches Cursor's own pre-write read threshold (`local-exec` 52428800). */
508
498
  const MAX_EDIT_SOURCE_BYTES = 50 * 1024 * 1024;
499
+ /**
500
+ * Complete the private read inside Cursor's legacy edit transaction without
501
+ * routing it through OpenCode's user-facing `read` tool. The latter caps every
502
+ * result at 50 KB, while Cursor's editor needs the complete file to calculate
503
+ * the correlated whole-file `write_args` that follows.
504
+ *
505
+ * The caller is responsible for proving that `filePath` is the same regular
506
+ * file named by an active `edit_tool_call` and is contained by the workspace.
507
+ */
508
+ export function buildCompleteEditReadMessages(execId, sourcePath, resultPath = sourcePath) {
509
+ try {
510
+ const stat = fs.statSync(sourcePath);
511
+ if (!stat.isFile() || stat.size > MAX_EDIT_SOURCE_BYTES)
512
+ return undefined;
513
+ const content = fs.readFileSync(sourcePath, "utf8");
514
+ return [
515
+ encodeMessage("AgentClientMessage", {
516
+ exec_client_message: {
517
+ id: execId,
518
+ local_execution_time_ms: 0,
519
+ read_result: {
520
+ success: {
521
+ path: resultPath,
522
+ content,
523
+ total_lines: countLines(content),
524
+ file_size: stat.size,
525
+ truncated: false,
526
+ range_applied: false,
527
+ },
528
+ },
529
+ },
530
+ }),
531
+ buildExecStreamClose(execId),
532
+ ];
533
+ }
534
+ catch {
535
+ return undefined;
536
+ }
537
+ }
509
538
  /** Return the number of non-overlapping occurrences, stopping after ambiguity. */
510
539
  function replacementOccurrenceCount(source, needle) {
511
540
  if (!needle)
@@ -643,6 +672,12 @@ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames,
643
672
  export function remapEditToolsForCatalog(parsed, advertisedToolNames, workspaceRoot) {
644
673
  if (parsed.toolName !== "write" && parsed.toolName !== "edit")
645
674
  return;
675
+ // `apply_patch` envelopes are text. A binary write has no patch form at all,
676
+ // so it must stay a write and reach the byte-preserving path — converting it
677
+ // here would drop the bytes and report a patch-translation failure instead of
678
+ // the image Cursor asked to save.
679
+ if (binaryWritePayload(parsed))
680
+ return;
646
681
  const advertised = new Set(advertisedToolNames);
647
682
  if (advertised.has(parsed.toolName) || !advertised.has(APPLY_PATCH_TOOL))
648
683
  return;
@@ -709,6 +744,43 @@ export function remapEditToolsForCatalog(parsed, advertisedToolNames, workspaceR
709
744
  // the requested path for the typed Cursor result.
710
745
  parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
711
746
  }
747
+ /**
748
+ * A partial-read notice is for Cursor's model, never for a file. Refuse only
749
+ * whole-file mutation forms that echo it. A targeted edit or Update File patch
750
+ * cannot truncate an unseen tail, and may legitimately edit source that quotes
751
+ * this provider's warning text.
752
+ */
753
+ export function rejectPartialReadMutation(parsed) {
754
+ if (parsed.localError)
755
+ return;
756
+ let content;
757
+ if (parsed.toolName === "write") {
758
+ content = parsed.args.content;
759
+ }
760
+ else if (parsed.toolName === APPLY_PATCH_TOOL) {
761
+ const patchText = parsed.args.patchText;
762
+ if (typeof patchText !== "string" || !patchText.includes("*** Add File:"))
763
+ return;
764
+ content = patchText;
765
+ }
766
+ else {
767
+ return;
768
+ }
769
+ if (typeof content !== "string"
770
+ || !content.includes("[Partial read:")
771
+ || !content.includes("It is NOT the complete file."))
772
+ return;
773
+ const filePath = typeof parsed.args.filePath === "string"
774
+ ? parsed.args.filePath
775
+ : "the target file";
776
+ const nextOffset = /Continue with offset=(\d+)/.exec(content)?.[1];
777
+ parsed.localError =
778
+ `NO FILE CHANGE WAS MADE. Refusing a whole-file mutation of ${JSON.stringify(filePath)} ` +
779
+ "because it contains the provider's partial-read notice. Do not retry the same mutation. " +
780
+ (nextOffset
781
+ ? `Read the file from offset=${nextOffset}, then use a targeted edit or Update File patch.`
782
+ : "Read the remaining file ranges, then use a targeted edit or Update File patch.");
783
+ }
712
784
  export function parseExecServerMessage(msg) {
713
785
  const id = msg.id;
714
786
  if (id === undefined)
@@ -864,7 +936,19 @@ export function parseExecServerMessage(msg) {
864
936
  toolName: mapped.toolName,
865
937
  args: mapped.args,
866
938
  resultField,
867
- ...(resultMetadata ? { resultMetadata } : {}),
939
+ ...(resultMetadata || mapped.binaryBytes
940
+ ? {
941
+ resultMetadata: {
942
+ ...resultMetadata,
943
+ ...(mapped.binaryBytes
944
+ ? {
945
+ binaryWriteBytes: mapped.binaryBytes,
946
+ path: str(rawArgs.path) ?? str(rawArgs.file_path) ?? "",
947
+ }
948
+ : {}),
949
+ },
950
+ }
951
+ : {}),
868
952
  };
869
953
  }
870
954
  function shellStreamResultMetadata(raw) {
@@ -944,14 +1028,31 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
944
1028
  // non-empty and only falls back to `file_text`; mirror that order so a
945
1029
  // byte-encoded write is not silently seen as empty content.
946
1030
  const bytes = bytesValue(cleaned.file_bytes) ?? bytesValue(cleaned.fileBytes);
947
- const content = bytes !== undefined
948
- ? decodeWriteBytes(bytes, str(cleaned.encoding_hint) ?? str(cleaned.encodingHint))
949
- : stringValue(cleaned.content) ?? stringValue(cleaned.file_text) ?? stringValue(cleaned.fileText);
1031
+ let content;
1032
+ if (bytes !== undefined) {
1033
+ content = decodeWriteBytes(bytes, str(cleaned.encoding_hint) ?? str(cleaned.encodingHint));
1034
+ if (content === undefined) {
1035
+ // Binary. Surface the raw bytes to the caller instead of a lossy
1036
+ // decode; `binaryWritePayload` picks them up after parsing.
1037
+ return { toolName: "write", args, binaryBytes: bytes };
1038
+ }
1039
+ }
1040
+ else {
1041
+ content = stringValue(cleaned.content)
1042
+ ?? stringValue(cleaned.file_text)
1043
+ ?? stringValue(cleaned.fileText);
1044
+ }
950
1045
  if (content !== undefined)
951
1046
  args.content = content;
952
1047
  return { toolName: "write", args };
953
1048
  }
954
1049
  case "edit": {
1050
+ // Preserve an opaque textual edit payload when the advertised executor
1051
+ // accepts that generic shape; rebuilding only OpenCode's targeted fields
1052
+ // would silently turn a valid call into `{}`.
1053
+ if (typeof cleaned.input === "string") {
1054
+ return { toolName: "edit", args: { input: cleaned.input } };
1055
+ }
955
1056
  const args = {};
956
1057
  const filePath = str(cleaned.filePath) ?? str(cleaned.path) ?? str(cleaned.file_path);
957
1058
  if (filePath)
@@ -1029,22 +1130,56 @@ function bytesValue(v) {
1029
1130
  }
1030
1131
  return undefined;
1031
1132
  }
1133
+ /** UTF-16/32 embed NUL bytes in ordinary text; 8-bit encodings do not. */
1134
+ const WIDE_TEXT_ENCODING = /^utf-?(16|32)/;
1032
1135
  /**
1033
1136
  * Decode `WriteArgs.file_bytes`. `encoding_hint` names the file's original
1034
- * encoding; OpenCode's `write` takes a string, so anything Node cannot decode
1137
+ * encoding; OpenCode's `write` takes a string, so an unknown encoding label
1035
1138
  * falls back to UTF-8 rather than dropping the write.
1139
+ *
1140
+ * Returns undefined when the bytes are not text in that encoding. A non-fatal
1141
+ * decode would replace every undecodable sequence with U+FFFD and hand the host
1142
+ * a corrupted file that still reports success, so binary content must take the
1143
+ * byte-preserving path instead (see `isBinaryWriteRequest`).
1036
1144
  */
1037
- function decodeWriteBytes(bytes, encodingHint) {
1145
+ export function decodeWriteBytes(bytes, encodingHint) {
1038
1146
  const encoding = encodingHint?.trim().toLowerCase().replace(/[_ ]/g, "-");
1147
+ let decoder;
1148
+ let label = "utf-8";
1039
1149
  if (encoding && encoding !== "utf-8" && encoding !== "utf8") {
1040
1150
  try {
1041
- return new TextDecoder(encoding, { fatal: false }).decode(bytes);
1151
+ decoder = new TextDecoder(encoding, { fatal: true });
1152
+ label = encoding;
1042
1153
  }
1043
1154
  catch {
1044
1155
  trace(`write: unsupported encoding_hint ${JSON.stringify(encodingHint)} — decoding as utf-8`);
1045
1156
  }
1046
1157
  }
1047
- return new TextDecoder("utf-8").decode(bytes);
1158
+ if (!decoder)
1159
+ decoder = new TextDecoder("utf-8", { fatal: true });
1160
+ // Single-byte encodings decode any byte sequence without error, so a decode
1161
+ // that cannot fail is not on its own evidence of text.
1162
+ if (!WIDE_TEXT_ENCODING.test(label) && bytes.includes(0))
1163
+ return undefined;
1164
+ try {
1165
+ return decoder.decode(bytes);
1166
+ }
1167
+ catch {
1168
+ return undefined;
1169
+ }
1170
+ }
1171
+ /**
1172
+ * True when a parsed `write` request carries bytes that are not text. Such a
1173
+ * request cannot go to OpenCode's `write` tool at all — it takes a string, and
1174
+ * additionally BOM-splits, diffs, and runs `Format.file()` on what it writes.
1175
+ */
1176
+ export function binaryWritePayload(parsed) {
1177
+ if (parsed.toolName !== "write")
1178
+ return undefined;
1179
+ const data = parsed.resultMetadata?.binaryWriteBytes;
1180
+ if (!(data instanceof Uint8Array))
1181
+ return undefined;
1182
+ return { path: str(parsed.resultMetadata?.path) ?? "", data };
1048
1183
  }
1049
1184
  function num(v) {
1050
1185
  if (typeof v === "number" && Number.isFinite(v))
@@ -1127,15 +1262,10 @@ function untildify(input) {
1127
1262
  * A read target addressed by URI scheme rather than by local filesystem path.
1128
1263
  *
1129
1264
  * Cursor's LocalReadExecutor only ever reads local files, so this provider
1130
- * mirrors its pre-execution validation. Host read tools are not so limited:
1131
- * oh-my-pi's `read` accepts "a path, URL, or internal URI", and mounts every
1132
- * discoverable tool — including all MCP server tools — under `xd://<tool>`
1133
- * device URLs that are read for their schema and written to for execution
1134
- * (`tools.xdev`, on by default). Resolving those against the workspace root
1135
- * mangles `xd://everything_echo` into `<root>/xd:/everything_echo`, which stats
1136
- * as missing and gets refused as `file_not_found` — so the host's read tool is
1137
- * never called and every xd://-mounted MCP tool is unreachable through this
1138
- * provider. Such targets must be forwarded untouched and left to the host.
1265
+ * mirrors its pre-execution validation. An advertised host read tool may also
1266
+ * accept URLs or internal URI schemes. Resolving those against the workspace
1267
+ * root mangles the URI and produces a false `file_not_found`, so URI targets
1268
+ * are forwarded untouched and left to the advertised executor.
1139
1269
  *
1140
1270
  * Requires a scheme of two or more characters followed by `://`, so Windows
1141
1271
  * drive letters (`C:\src`, `C:/src`) stay local paths.
package/dist/session.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { BidiStream } from "./transport/connect.js";
2
2
  import { type CursorProviderError } from "./errors.js";
3
3
  import { type SessionActivitySource } from "./activity.js";
4
- import type { HostSubagentCatalog, ToolAliasRegistry } from "./protocol/tools.js";
4
+ import type { HostSubagentCatalog, OpencodeToolDef, ToolAliasRegistry } from "./protocol/tools.js";
5
+ import type { CursorConversationTokenDetails } from "./protocol/token-details.js";
5
6
  export type Frame = {
6
7
  flags: number;
7
8
  payload: Uint8Array;
@@ -99,10 +100,44 @@ export type CursorSession = {
99
100
  * conversation_checkpoint_update (CLI parity).
100
101
  */
101
102
  conversationId: string;
103
+ /** Cache root owning this conversation's durable restart state. */
104
+ cacheDir?: string;
102
105
  /** Latest eligible checkpoint emitted by this Run attempt. */
103
106
  resumeCheckpoint?: Uint8Array;
107
+ /** Last checkpoint-derived context snapshot, independent of retry eligibility. */
108
+ tokenDetails?: CursorConversationTokenDetails;
109
+ /** True only after this Run receives a checkpoint containing token details. */
110
+ tokenDetailsFresh?: boolean;
111
+ /**
112
+ * Run-lifetime cache evidence. Cursor exposes only aggregate cache counters at
113
+ * TurnEnded, so retain the inputs and protocol activity needed to explain
114
+ * whether a low ratio came from a cold/rebased prefix, context growth, or a
115
+ * multi-step agent Run.
116
+ */
117
+ cacheDiagnostics?: {
118
+ sessionKey?: string;
119
+ conversationId: string;
120
+ conversationGroupId?: string;
121
+ modelId?: string;
122
+ priorTokenDetails?: CursorConversationTokenDetails;
123
+ startedWithCheckpoint: boolean;
124
+ requestContextReused: boolean;
125
+ requestContextHash: string;
126
+ systemPromptHash?: string;
127
+ checkpointUpdates: number;
128
+ tokenDetailUpdates: number;
129
+ pumpPasses: number;
130
+ stepStarts: number;
131
+ stepCompletes: number;
132
+ displayToolCalls: number;
133
+ execRequests: number;
134
+ };
104
135
  /** OpenCode session whose own or descendant activity renews tool leases. */
105
136
  openCodeSessionId?: string;
137
+ /** Completed compaction must rebase once before resuming a normal agent. */
138
+ postCompactionRebase?: boolean;
139
+ /** Last real host catalog, retained only as a lifecycle-turn fallback. */
140
+ toolCatalog?: OpencodeToolDef[];
106
141
  stream: BidiStream;
107
142
  frames: AsyncIterator<Frame>;
108
143
  pending: Map<number, PendingExec>;
@@ -119,6 +154,7 @@ export type CursorSession = {
119
154
  */
120
155
  editToolCalls?: Map<string, {
121
156
  path: string;
157
+ completeRead?: boolean;
122
158
  }>;
123
159
  /** Monotonic synthetic exec ids for bridged (display-only) OpenCode tool calls. */
124
160
  nextBridgedExecId: number;
@@ -139,9 +175,8 @@ export type CursorSession = {
139
175
  */
140
176
  allowTools: boolean;
141
177
  /**
142
- * Best-effort token usage for this held-open Run. Updated from text/tool
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.
178
+ * Best-effort held-Run activity counters used only for diagnostics. AI SDK
179
+ * accounting comes from the request-local final TurnEnded, never this estimate.
145
180
  */
146
181
  usageEstimate: {
147
182
  inputTokens: number;
@@ -150,13 +185,6 @@ export type CursorSession = {
150
185
  cacheWrite: number;
151
186
  reasoningTokens: number;
152
187
  };
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;
160
188
  /**
161
189
  * True while a doStream pull() is actively reading this session's frames.
162
190
  * Prevents a late cancel/abort from a prior ReadableStream from destroying
package/dist/shared.d.ts CHANGED
@@ -12,6 +12,9 @@ export declare const MODEL_CACHE_FILE = "cursor-models.json";
12
12
  /** Bumped when the on-disk model cache shape/semantics change (forces refetch). */
13
13
  export declare const MODEL_CACHE_SCHEMA_VERSION = 3;
14
14
  export declare const MODEL_CACHE_TTL_MS = 86400000;
15
+ export declare const CONVERSATION_CACHE_DIR = "cursor-conversations";
16
+ export declare const CONVERSATION_CACHE_SCHEMA_VERSION = 3;
17
+ export declare const CONVERSATION_CACHE_TTL_MS = 86400000;
15
18
  export declare const VERSION_CACHE_FILE = "cursor-client-version.json";
16
19
  export declare const CONTENT_TYPE_CONNECT_PROTO = "application/connect+proto";
17
20
  export declare const CONNECT_PROTOCOL_VERSION = "1";
package/dist/shared.js CHANGED
@@ -12,6 +12,9 @@ export const MODEL_CACHE_FILE = "cursor-models.json";
12
12
  /** Bumped when the on-disk model cache shape/semantics change (forces refetch). */
13
13
  export const MODEL_CACHE_SCHEMA_VERSION = 3;
14
14
  export const MODEL_CACHE_TTL_MS = 86_400_000;
15
+ export const CONVERSATION_CACHE_DIR = "cursor-conversations";
16
+ export const CONVERSATION_CACHE_SCHEMA_VERSION = 3;
17
+ export const CONVERSATION_CACHE_TTL_MS = 86_400_000;
15
18
  export const VERSION_CACHE_FILE = "cursor-client-version.json";
16
19
  export const CONTENT_TYPE_CONNECT_PROTO = "application/connect+proto";
17
20
  export const CONNECT_PROTOCOL_VERSION = "1";
package/dist/usage.d.ts CHANGED
@@ -1,51 +1,56 @@
1
1
  import type { LanguageModelV3Usage } from "@ai-sdk/provider";
2
- /** Non-negative integer counter from a Cursor `turn_ended` field. */
3
- export declare function turnEndedCounter(te: Record<string, unknown>, key: string): number;
4
- /**
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.
8
- *
9
- * Contract: `input_tokens` is non-cached input; cache fields are separate.
10
- * Cursor's agent wire currently sends `cache_write=0` even when later turns
11
- * report large `cache_read`; we still forward the counter when non-zero.
12
- * If production semantics differ (input_tokens already total), adjust here.
13
- */
14
- export declare function buildLanguageModelV3UsageFromTurnEnded(te: Record<string, unknown>): LanguageModelV3Usage;
15
- export type CursorUsageEstimate = {
2
+ import type { CursorContextUsageSource, CursorConversationTokenDetails } from "./protocol/token-details.js";
3
+ export type CursorUsageCounters = {
16
4
  inputTokens: number;
17
5
  outputTokens: number;
18
6
  cacheRead: number;
19
7
  cacheWrite: number;
20
8
  reasoningTokens: number;
21
9
  };
10
+ export type CursorUsageOptions = {
11
+ /** Cursor checkpoint occupancy, including the current turn's output. */
12
+ contextTotalTokens?: number;
13
+ };
14
+ export type CursorCacheDiagnosticStats = {
15
+ sessionKey?: string;
16
+ conversationId: string;
17
+ conversationGroupId?: string;
18
+ modelId?: string;
19
+ startedWithCheckpoint: boolean;
20
+ requestContextReused: boolean;
21
+ requestContextHash: string;
22
+ systemPromptHash?: string;
23
+ checkpointUpdates: number;
24
+ tokenDetailUpdates: number;
25
+ pumpPasses: number;
26
+ stepStarts: number;
27
+ stepCompletes: number;
28
+ displayToolCalls: number;
29
+ execRequests: number;
30
+ };
31
+ /** Non-negative integer counter from a Cursor `turn_ended` field. */
32
+ export declare function turnEndedCounter(te: Record<string, unknown>, key: string): number;
33
+ export declare function cursorUsageCountersFromTurnEnded(te: Record<string, unknown>): CursorUsageCounters;
22
34
  /**
23
- * A single-step trust path for TurnEnded counters is only safe when the
24
- * totals are close to the request-local budget. Cursor reports cumulative
25
- * conversation/Run totals even on Runs that never crossed an OpenCode tool
26
- * boundary: single-step follow-up turns and parallel no-tools side calls
27
- * carry the whole conversation's `cache_read`. Treating those totals as
28
- * request-local inflates OpenCode's context usage and triggers premature
29
- * compaction (issue #20).
35
+ * Map Cursor counters to AI SDK V3. Cursor's `input_tokens` already includes
36
+ * cache reads/writes, and `output_tokens` already includes reasoning.
30
37
  */
31
- export declare const CUMULATIVE_TURN_ENDED_RATIO = 3;
32
- export declare const MIN_TRUSTED_TURN_ENDED_TOTAL = 1024;
33
- export declare function exceedsRequestLocalBudget(te: Record<string, unknown>, budget: {
34
- inputTokens: number;
35
- outputTokens: number;
36
- }): boolean;
38
+ export declare function buildLanguageModelV3UsageFromCounters(counters: CursorUsageCounters, options?: CursorUsageOptions): LanguageModelV3Usage;
39
+ export declare function buildLanguageModelV3UsageFromTurnEnded(te: Record<string, unknown>, options?: CursorUsageOptions): LanguageModelV3Usage;
40
+ /** Compact, parseable category snapshot for checkpoint-by-checkpoint traces. */
41
+ export declare function formatCursorTokenCategories(details: CursorConversationTokenDetails | undefined): string;
37
42
  /**
38
- * Request-local usage for a held Cursor Run.
39
- *
40
- * `est.inputTokens` tracks the seed plus delivered tool results, while
41
- * `est.outputTokens` accumulates model output across earlier tool boundaries.
42
- * Move prior output into the current input estimate and report only this
43
- * doStream request's output. Cache counters are intentionally absent: the
44
- * only counters Cursor exposes at TurnEnded are cumulative for the whole Run.
43
+ * Explain Cursor's aggregate cache counters using the state visible to this
44
+ * client. `stepStarts` is deliberately not called a model-call count: Cursor
45
+ * does not expose per-model-call cache accounting on the Run stream.
45
46
  */
46
- export declare function buildLanguageModelV3UsageFromEstimate(est: CursorUsageEstimate, promptTokens: number, outputChars: number, estimateCharsToTokens: (chars: number) => number): LanguageModelV3Usage;
47
- /** Mirror Kilo `Session.getUsage` / AI SDK flat fields from V3 nested usage. */
48
- export declare function kiloShapedUsageFromV3(usage: LanguageModelV3Usage): {
47
+ export declare function formatCursorCacheDiagnostics(counters: CursorUsageCounters, current: CursorConversationTokenDetails | undefined, prior: CursorConversationTokenDetails | undefined, stats: CursorCacheDiagnosticStats): string;
48
+ /** One-line proof that Cursor, AI SDK, and projected OpenCode totals agree. */
49
+ export declare function formatTurnUsageValidation(counters: CursorUsageCounters, usage: LanguageModelV3Usage, tokenDetails?: CursorConversationTokenDetails, contextSource?: CursorContextUsageSource): string;
50
+ /** OpenCode requires a usage object at every step boundary. */
51
+ export declare function emptyLanguageModelV3Usage(): LanguageModelV3Usage;
52
+ /** Project nested V3 usage into the common flat AI-SDK counter shape. */
53
+ export declare function flatUsageFromV3(usage: LanguageModelV3Usage): {
49
54
  inputTokens: number;
50
55
  outputTokens: number;
51
56
  reasoningTokens: number;