cursor-opencode-provider 0.8.0 → 0.8.3

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 (132) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +230 -44
  3. package/SECURITY.md +60 -12
  4. package/dist/activity.d.ts +73 -1
  5. package/dist/activity.js +231 -5
  6. package/dist/agent-url.js +37 -9
  7. package/dist/auth-renewal.d.ts +1 -1
  8. package/dist/auth-renewal.js +58 -26
  9. package/dist/billing.d.ts +105 -0
  10. package/dist/billing.js +212 -0
  11. package/dist/classic-tool-results.d.ts +1 -0
  12. package/dist/classic-tool-results.js +42 -0
  13. package/dist/context/build.d.ts +23 -4
  14. package/dist/context/build.js +65 -10
  15. package/dist/context/dynamic-catalog.js +6 -1
  16. package/dist/context/env.js +27 -3
  17. package/dist/context/epoch.js +9 -5
  18. package/dist/context/frozen.js +6 -2
  19. package/dist/context/git.d.ts +5 -2
  20. package/dist/context/git.js +17 -7
  21. package/dist/context/index.d.ts +2 -0
  22. package/dist/context/index.js +2 -0
  23. package/dist/context/paths.d.ts +45 -18
  24. package/dist/context/paths.js +72 -22
  25. package/dist/context/skills-bridge.d.ts +22 -0
  26. package/dist/context/skills-bridge.js +20 -0
  27. package/dist/context/skills.d.ts +34 -0
  28. package/dist/context/skills.js +211 -0
  29. package/dist/debug.js +13 -2
  30. package/dist/early-tool-rows.d.ts +60 -0
  31. package/dist/early-tool-rows.js +96 -0
  32. package/dist/errors.d.ts +49 -7
  33. package/dist/errors.js +163 -8
  34. package/dist/host-agent-mode.d.ts +53 -4
  35. package/dist/host-agent-mode.js +117 -13
  36. package/dist/host-interrupt.d.ts +12 -0
  37. package/dist/host-interrupt.js +18 -0
  38. package/dist/host-plan-file.d.ts +28 -0
  39. package/dist/host-plan-file.js +76 -0
  40. package/dist/host-steer.d.ts +71 -0
  41. package/dist/host-steer.js +232 -0
  42. package/dist/image-input.d.ts +18 -0
  43. package/dist/image-input.js +122 -30
  44. package/dist/image-save.d.ts +10 -2
  45. package/dist/image-save.js +33 -21
  46. package/dist/index.d.ts +2 -2
  47. package/dist/language-model.d.ts +149 -30
  48. package/dist/language-model.js +3127 -673
  49. package/dist/lost-request.d.ts +39 -0
  50. package/dist/lost-request.js +111 -0
  51. package/dist/model-config.d.ts +12 -1
  52. package/dist/model-config.js +78 -30
  53. package/dist/models.d.ts +26 -3
  54. package/dist/models.js +101 -45
  55. package/dist/opencode2/catalog.d.ts +1 -0
  56. package/dist/opencode2/catalog.js +2 -0
  57. package/dist/opencode2/image-save-tool.d.ts +8 -0
  58. package/dist/opencode2/image-save-tool.js +84 -0
  59. package/dist/opencode2/tool-results.d.ts +18 -0
  60. package/dist/opencode2/tool-results.js +66 -0
  61. package/dist/opencode2/types.d.ts +15 -0
  62. package/dist/parallel-step.d.ts +65 -0
  63. package/dist/parallel-step.js +102 -0
  64. package/dist/plugin-opencode2.js +233 -65
  65. package/dist/plugin.js +97 -23
  66. package/dist/pricing-data.d.ts +40 -3
  67. package/dist/pricing-data.js +40 -3
  68. package/dist/pricing.d.ts +1 -1
  69. package/dist/pricing.js +7 -4
  70. package/dist/process-shared.d.ts +14 -0
  71. package/dist/process-shared.js +18 -0
  72. package/dist/protocol/apply-patch.d.ts +25 -13
  73. package/dist/protocol/apply-patch.js +79 -30
  74. package/dist/protocol/ask-question.d.ts +12 -1
  75. package/dist/protocol/ask-question.js +77 -3
  76. package/dist/protocol/blob-store.d.ts +5 -1
  77. package/dist/protocol/blob-store.js +10 -6
  78. package/dist/protocol/client-version.js +6 -4
  79. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  80. package/dist/protocol/conversation-cache-header.js +149 -0
  81. package/dist/protocol/conversation-persistence.d.ts +27 -1
  82. package/dist/protocol/conversation-persistence.js +628 -126
  83. package/dist/protocol/conversation-state.d.ts +8 -1
  84. package/dist/protocol/conversation-state.js +28 -7
  85. package/dist/protocol/create-plan.d.ts +47 -25
  86. package/dist/protocol/create-plan.js +33 -23
  87. package/dist/protocol/host-notes.d.ts +31 -0
  88. package/dist/protocol/host-notes.js +85 -0
  89. package/dist/protocol/interactions.d.ts +26 -17
  90. package/dist/protocol/interactions.js +80 -31
  91. package/dist/protocol/messages.js +164 -21
  92. package/dist/protocol/request.d.ts +40 -19
  93. package/dist/protocol/request.js +55 -28
  94. package/dist/protocol/sent-history-images.d.ts +49 -0
  95. package/dist/protocol/sent-history-images.js +102 -0
  96. package/dist/protocol/switch-mode.d.ts +66 -27
  97. package/dist/protocol/switch-mode.js +147 -73
  98. package/dist/protocol/token-details.d.ts +2 -0
  99. package/dist/protocol/token-details.js +11 -1
  100. package/dist/protocol/tool-call-bridge.d.ts +6 -0
  101. package/dist/protocol/tool-call-bridge.js +24 -0
  102. package/dist/protocol/tools.d.ts +90 -9
  103. package/dist/protocol/tools.js +543 -151
  104. package/dist/protocol/turn-provenance.d.ts +19 -4
  105. package/dist/protocol/turn-provenance.js +77 -20
  106. package/dist/replay-safety.d.ts +22 -4
  107. package/dist/replay-safety.js +86 -27
  108. package/dist/retry-budget.d.ts +21 -0
  109. package/dist/retry-budget.js +94 -0
  110. package/dist/runtime-files.d.ts +23 -0
  111. package/dist/runtime-files.js +581 -0
  112. package/dist/session.d.ts +129 -10
  113. package/dist/session.js +198 -35
  114. package/dist/shared.d.ts +1 -1
  115. package/dist/shared.js +1 -1
  116. package/dist/shell-timeout.d.ts +54 -10
  117. package/dist/shell-timeout.js +362 -92
  118. package/dist/stream-write.d.ts +20 -0
  119. package/dist/stream-write.js +87 -0
  120. package/dist/terminal-runner.d.ts +15 -0
  121. package/dist/terminal-runner.js +528 -0
  122. package/dist/todo-store.d.ts +2 -0
  123. package/dist/todo-store.js +19 -0
  124. package/dist/transport/connect.d.ts +10 -1
  125. package/dist/transport/connect.js +124 -27
  126. package/dist/usage.d.ts +37 -17
  127. package/dist/usage.js +85 -34
  128. package/package.json +5 -3
  129. package/dist/plan-execution-kickoff.d.ts +0 -57
  130. package/dist/plan-execution-kickoff.js +0 -186
  131. package/dist/protocol/thinking.d.ts +0 -15
  132. package/dist/protocol/thinking.js +0 -17
@@ -1,18 +1,44 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { encodeMessage } from "./messages.js";
4
+ import { encodeMessage, getMessageTypes } from "./messages.js";
5
5
  import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
6
6
  import { buildEnv } from "../context/env.js";
7
7
  import { ensureOpencodeProjectDir } from "../context/paths.js";
8
8
  import { trace, traceRequestContextPaths } from "../debug.js";
9
9
  import { cursorExecVariantByRequestName, FORCE_BACKGROUND_STATUS_ERROR, } from "./exec-variants.js";
10
- import { APPLY_PATCH_TOOL, buildAddFilePatch, buildUpdateFilePatch, planSubstringEdit, } from "./apply-patch.js";
10
+ import { buildAddFilePatch, buildUpdateFilePatch, hostPatchTool, isHostPatchTool, planSubstringEdit, } from "./apply-patch.js";
11
11
  import { BACKGROUND_SHELL_MARKER, buildBackgroundShellCommand, } from "../shell-timeout.js";
12
+ import { normalizeOpencodeQuestionArgs } from "./ask-question.js";
13
+ import { normalizeOpencodeTodoArgs } from "../todo-store.js";
12
14
  // Exec variant field number whose reply is the server-initiated request_context
13
15
  // probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
14
16
  // field number for every exec variant, so this is also the result field.
15
17
  export const REQUEST_CONTEXT_RESULT_FIELD = 10;
18
+ /** Check explicit required keys after normalization, before host execution. */
19
+ export function missingRequiredToolArguments(tool, args) {
20
+ const schema = tool?.inputSchema;
21
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
22
+ return [];
23
+ const required = schema.required;
24
+ if (!Array.isArray(required))
25
+ return [];
26
+ return required.filter((key) => typeof key === "string" && (!Object.hasOwn(args, key) || args[key] === undefined));
27
+ }
28
+ /** Canonical file/search tools must not silently ignore a misplaced shell command. */
29
+ export function misplacedShellCommand(tool, args) {
30
+ if (!tool || args.command === undefined)
31
+ return false;
32
+ if (!["read", "write", "edit", "grep", "glob", "ls"].includes(tool.name))
33
+ return false;
34
+ const schema = tool.inputSchema;
35
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
36
+ return false;
37
+ const properties = schema.properties;
38
+ if (!properties || typeof properties !== "object" || Array.isArray(properties))
39
+ return false;
40
+ return !Object.hasOwn(properties, "command");
41
+ }
16
42
  export const OPENCODE_1_TOOL_DIALECT = {
17
43
  filePathKey: "filePath",
18
44
  shellTool: "bash",
@@ -88,7 +114,20 @@ export function hostToolDialectFromTools(tools, defaultDialect = OPENCODE_1_TOOL
88
114
  ? "id"
89
115
  : defaultDialect.skillArgKey;
90
116
  }
91
- return { filePathKey, shellTool, skillArgKey };
117
+ const shellSchema = tools.find((tool) => tool.name === shellTool)?.inputSchema;
118
+ const shellProps = jsonSchemaProperties(shellSchema);
119
+ const description = shellProps?.description;
120
+ const required = shellSchema && typeof shellSchema === "object" && !Array.isArray(shellSchema)
121
+ ? shellSchema.required
122
+ : undefined;
123
+ return {
124
+ filePathKey, shellTool, skillArgKey,
125
+ ...(description && typeof description === "object"
126
+ && description.type === "string"
127
+ ? { shellDescription: Array.isArray(required) && required.includes("description")
128
+ ? "required" : "optional" }
129
+ : {}),
130
+ };
92
131
  }
93
132
  function assignHostFilePath(args, filePath, dialect) {
94
133
  delete args.filePath;
@@ -628,6 +667,46 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
628
667
  }
629
668
  /** Matches Cursor's own pre-write read threshold (`local-exec` 52428800). */
630
669
  const MAX_EDIT_SOURCE_BYTES = 50 * 1024 * 1024;
670
+ export function cursorEditReadAnswer(absolutePath, workspaceRoot) {
671
+ try {
672
+ fs.lstatSync(absolutePath);
673
+ }
674
+ catch (error) {
675
+ return error.code === "ENOENT" ? "empty" : "host";
676
+ }
677
+ // Do not bypass OpenCode's external-directory permission boundary.
678
+ let relative;
679
+ try {
680
+ relative = path.relative(fs.realpathSync(workspaceRoot), fs.realpathSync(absolutePath));
681
+ }
682
+ catch {
683
+ return "external";
684
+ }
685
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
686
+ return "external";
687
+ try {
688
+ const stat = fs.statSync(absolutePath);
689
+ if (!stat.isFile() || stat.size > MAX_EDIT_SOURCE_BYTES)
690
+ return "host";
691
+ fs.closeSync(fs.openSync(absolutePath, "r"));
692
+ return "complete";
693
+ }
694
+ catch {
695
+ return "host";
696
+ }
697
+ }
698
+ /**
699
+ * The edit's private read becomes its own host `read` call. A read the edit
700
+ * handshake leaves to the host can still be answered by the provider's
701
+ * missing-target validation (a dangling symlink, a directory); that read
702
+ * never reaches the host.
703
+ */
704
+ export function cursorEditReadReachesHost(absolutePath, workspaceRoot) {
705
+ const answer = cursorEditReadAnswer(absolutePath, workspaceRoot);
706
+ if (answer !== "external" && answer !== "host")
707
+ return false;
708
+ return classifyMissingReadTarget(absolutePath) === undefined;
709
+ }
631
710
  /**
632
711
  * Complete the private read inside Cursor's legacy edit transaction without
633
712
  * routing it through OpenCode's user-facing `read` tool. The latter caps every
@@ -667,6 +746,60 @@ export function buildCompleteEditReadMessages(execId, sourcePath, resultPath = s
667
746
  return undefined;
668
747
  }
669
748
  }
749
+ /** 1x1 transparent PNG: satisfies Cursor's binary-attachment check without file bytes. */
750
+ const TASK_ATTACHMENT_PLACEHOLDER = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", "base64");
751
+ /**
752
+ * Answer Cursor's read of a Task attachment without the file's bytes. Cursor
753
+ * puts them only in SubagentArgs.selected_context, which OpenCode's subagent
754
+ * tools cannot take, yet refuses the Task unless the read returns binary data.
755
+ * The path reaches the subagent prompt instead ({@link appendTaskAttachments})
756
+ * and the child reads it under its own permissions. A missing or non-regular
757
+ * target gets the same typed rejection as any native read.
758
+ */
759
+ export function buildTaskAttachmentReadMessages(execId, sourcePath, resultPath = sourcePath) {
760
+ // Unlike an ordinary host read, this path has no later executor to surface
761
+ // stat errors. Never mistake EACCES/ELOOP (or invalid input) for a real file.
762
+ try {
763
+ const rejected = classifyReadTargetStat(sourcePath, fs.statSync(sourcePath));
764
+ if (rejected)
765
+ return buildReadRejectionMessages(execId, rejected);
766
+ }
767
+ catch (error) {
768
+ const code = error.code;
769
+ const rejected = code === "ENOENT" || code === "ENOTDIR"
770
+ ? { file_not_found: { path: sourcePath } }
771
+ : undefined;
772
+ return buildReadRejectionMessages(execId, rejected ?? {
773
+ error: { path: resultPath, error: error instanceof Error ? error.message : String(error) },
774
+ });
775
+ }
776
+ return [
777
+ encodeMessage("AgentClientMessage", {
778
+ exec_client_message: {
779
+ id: execId,
780
+ local_execution_time_ms: 0,
781
+ read_result: {
782
+ success: {
783
+ path: resultPath,
784
+ data: TASK_ATTACHMENT_PLACEHOLDER,
785
+ total_lines: 0,
786
+ file_size: TASK_ATTACHMENT_PLACEHOLDER.length,
787
+ truncated: false,
788
+ },
789
+ },
790
+ },
791
+ }),
792
+ buildExecStreamClose(execId),
793
+ ];
794
+ }
795
+ /** List Cursor's Task attachments at the end of the subagent prompt as absolute paths. */
796
+ export function appendTaskAttachments(parsed, attachments, workspaceRoot) {
797
+ if (parsed.resultField !== "subagent_result" || attachments.length === 0)
798
+ return;
799
+ const prompt = str(parsed.args.prompt) ?? "";
800
+ const paths = attachments.map((item) => `- ${JSON.stringify(resolveReadTargetPath(item, workspaceRoot))}`);
801
+ parsed.args.prompt = `${prompt}\n\nAttached files (open them with your file tools):\n${paths.join("\n")}`;
802
+ }
670
803
  /** Return the number of non-overlapping occurrences, stopping after ambiguity. */
671
804
  function replacementOccurrenceCount(source, needle) {
672
805
  if (!needle)
@@ -739,6 +872,41 @@ function planWholeFileEdit(source, target) {
739
872
  }
740
873
  return result;
741
874
  }
875
+ /**
876
+ * Host tool a Cursor edit announcement will become, using the same file facts
877
+ * as `remapCorrelatedEditWriteForCatalog` and `remapEditToolsForCatalog`. An
878
+ * existing file becomes `edit`, else the patch tool. A missing file is a
879
+ * creation, which stays `write`, else becomes the patch tool's Add File; so
880
+ * does an empty file, which has no text for an `edit` to replace. A name is
881
+ * withheld when those remaps would refuse the file or not produce that tool:
882
+ * no workspace for a relative path, not a regular file, too large,
883
+ * unreadable, or `patch` with carriage returns.
884
+ */
885
+ export function hostToolForCursorEdit(filePath, catalog, workspaceRoot) {
886
+ const patchTool = hostPatchTool(catalog);
887
+ if (!path.isAbsolute(filePath) && !workspaceRoot)
888
+ return undefined;
889
+ const absolute = path.resolve(workspaceRoot ?? process.cwd(), filePath);
890
+ try {
891
+ const stat = fs.statSync(absolute);
892
+ if (!stat.isFile() || stat.size > MAX_EDIT_SOURCE_BYTES)
893
+ return undefined;
894
+ if (stat.size === 0)
895
+ return catalog.has("write") ? "write" : patchTool;
896
+ if (catalog.has("edit"))
897
+ return "edit";
898
+ if (!patchTool)
899
+ return undefined;
900
+ if (patchTool === "patch" && /\r(?:\n|$)/.test(fs.readFileSync(absolute, "utf8")))
901
+ return undefined;
902
+ return patchTool;
903
+ }
904
+ catch (error) {
905
+ if (error.code !== "ENOENT")
906
+ return undefined;
907
+ return catalog.has("write") ? "write" : patchTool;
908
+ }
909
+ }
742
910
  /**
743
911
  * Preserve a legacy Cursor edit's intent when its internal executor follows
744
912
  * `edit_tool_call` with a whole-file `write_args` request.
@@ -750,7 +918,7 @@ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames,
750
918
  if (parsed.resultField !== "write_result" || parsed.toolName !== "write")
751
919
  return false;
752
920
  const advertised = new Set(advertisedToolNames);
753
- if (!advertised.has("edit") && !advertised.has(APPLY_PATCH_TOOL))
921
+ if (!advertised.has("edit") && !hostPatchTool(advertised))
754
922
  return false;
755
923
  const filePath = opencodePathArg(parsed.args);
756
924
  const content = stringValue(parsed.args.content);
@@ -789,93 +957,183 @@ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames,
789
957
  parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
790
958
  return true;
791
959
  }
960
+ /** Preserve file endings for direct dynamic patch calls as well as native edits. */
961
+ function preserveDirectPatchLineEndings(parsed, workspaceRoot) {
962
+ const patchText = stringValue(parsed.args.patchText);
963
+ if (patchText === undefined)
964
+ return;
965
+ try {
966
+ if (parsed.toolName === "patch" && /\r(?:\n|$)/.test(patchText)) {
967
+ throw new Error("the patch tool strips carriage returns from line endings; this content cannot be preserved");
968
+ }
969
+ const lines = patchText.split("\n");
970
+ let crlf = false;
971
+ for (let index = 0; index < lines.length; index++) {
972
+ const line = lines[index];
973
+ if (line.startsWith("*** Update File:")) {
974
+ crlf = false;
975
+ const target = path.resolve(workspaceRoot ?? process.cwd(), line.slice("*** Update File:".length).trim());
976
+ let source;
977
+ try {
978
+ const stat = fs.statSync(target);
979
+ // Let the host report missing targets and wrong file kinds normally.
980
+ if (!stat.isFile())
981
+ continue;
982
+ if (stat.size > MAX_EDIT_SOURCE_BYTES) {
983
+ throw new Error("the target file is too large to inspect safely for patch newline preservation");
984
+ }
985
+ source = fs.readFileSync(target, "utf8");
986
+ }
987
+ catch (error) {
988
+ if (error.code)
989
+ continue;
990
+ throw error;
991
+ }
992
+ if (source && !source.endsWith("\n")) {
993
+ throw new Error("the patch tool cannot safely update content without a final newline");
994
+ }
995
+ crlf = source.includes("\r\n");
996
+ if (!crlf)
997
+ continue;
998
+ if (parsed.toolName === "patch") {
999
+ throw new Error("the patch tool strips carriage returns from line endings; the source content cannot be preserved");
1000
+ }
1001
+ if (/(^|[^\r])\n/.test(source)) {
1002
+ throw new Error("direct patch context has mixed line endings; use a Cursor-native targeted edit to preserve them");
1003
+ }
1004
+ }
1005
+ else if (line.startsWith("***") && !line.startsWith("*** Move to:")) {
1006
+ crlf = false;
1007
+ }
1008
+ else if (crlf && /^[ +\-]/.test(line)) {
1009
+ // Context is copied into the result by the host, so it needs the same
1010
+ // CRLF restoration as additions. File headers and Add File stay exact.
1011
+ lines[index] = `${line.replace(/\r$/, "")}\r`;
1012
+ }
1013
+ }
1014
+ parsed.args.patchText = lines.join("\n");
1015
+ }
1016
+ catch (error) {
1017
+ parsed.args = {};
1018
+ parsed.localError = `Cursor ${parsed.toolName} request cannot preserve file contents: ${error instanceof Error ? error.message : String(error)}.`;
1019
+ }
1020
+ }
792
1021
  /**
793
- * Express a native Cursor write/edit as `apply_patch` when the host swapped the
1022
+ * Express a native Cursor write/edit as a patch call when the host swapped the
794
1023
  * edit-tool family out from under us.
795
1024
  *
796
- * OpenCode 1.x removes `edit` and `write` from the catalog for GPT models and
797
- * advertises `apply_patch` in their place — see the module comment in
798
- * `./apply-patch.ts` for the upstream reasoning and citations. Cursor keeps
799
- * using its native write/edit exec channel regardless, so without this the
800
- * request is refused as an unavailable tool and the model loses the ability to
801
- * change files at all.
1025
+ * Both OpenCode majors remove `edit` and `write` from the catalog for GPT
1026
+ * models and advertise a patch tool in their place (`apply_patch` on 1.x,
1027
+ * `patch` on 2.0) — see the module comment in `./apply-patch.ts` for the
1028
+ * upstream reasoning and citations. Cursor keeps using its native write/edit
1029
+ * exec channel regardless, so without this the request is refused as an
1030
+ * unavailable tool.
802
1031
  *
803
1032
  * Keyed purely off the advertised set: this is inert whenever the host offers
804
1033
  * `write`/`edit` normally, and equally inert when it offers neither those nor
805
- * `apply_patch` (the caller's unavailable-tool rejection still applies).
1034
+ * a patch tool (the caller's unavailable-tool rejection still applies).
806
1035
  */
807
1036
  export function remapEditToolsForCatalog(parsed, advertisedToolNames, workspaceRoot) {
1037
+ const advertised = new Set(advertisedToolNames);
1038
+ if (isHostPatchTool(parsed.toolName)) {
1039
+ if (advertised.has(parsed.toolName))
1040
+ preserveDirectPatchLineEndings(parsed, workspaceRoot);
1041
+ return;
1042
+ }
808
1043
  if (parsed.toolName !== "write" && parsed.toolName !== "edit")
809
1044
  return;
810
- // `apply_patch` envelopes are text. A binary write has no patch form at all,
1045
+ // Patch envelopes are text. A binary write has no patch form at all,
811
1046
  // so it must stay a write and reach the byte-preserving path — converting it
812
1047
  // here would drop the bytes and report a patch-translation failure instead of
813
1048
  // the image Cursor asked to save.
814
1049
  if (binaryWritePayload(parsed))
815
1050
  return;
816
- const advertised = new Set(advertisedToolNames);
817
- if (advertised.has(parsed.toolName) || !advertised.has(APPLY_PATCH_TOOL))
1051
+ const patchTool = hostPatchTool(advertised);
1052
+ if (advertised.has(parsed.toolName) || !patchTool)
818
1053
  return;
819
1054
  const requested = parsed.toolName;
820
1055
  const filePath = opencodePathArg(parsed.args);
821
1056
  const refuse = (reason) => {
822
- parsed.toolName = APPLY_PATCH_TOOL;
1057
+ parsed.toolName = patchTool;
823
1058
  parsed.args = {};
824
1059
  parsed.localError =
825
- `Cursor ${requested} request cannot be expressed as an apply_patch call: ${reason}. ` +
826
- "The host advertises `apply_patch` instead of `edit`/`write` for this model.";
1060
+ `Cursor ${requested} request cannot be expressed as a call to \`${patchTool}\`: ${reason}. ` +
1061
+ `The host advertises \`${patchTool}\` instead of \`edit\`/\`write\` for this model.`;
827
1062
  };
828
1063
  if (!filePath) {
829
1064
  refuse("no target path was provided");
830
1065
  return;
831
1066
  }
832
1067
  let patchText;
833
- if (requested === "write") {
834
- const content = stringValue(parsed.args.content);
835
- if (content === undefined) {
836
- refuse("no file content was provided");
837
- return;
838
- }
839
- // `*** Add File:` overwrites an existing target, so a whole-file write maps
840
- // directly without reading the current contents or diffing.
841
- patchText = buildAddFilePatch(filePath, content);
842
- }
843
- else {
844
- const oldString = stringValue(parsed.args.oldString);
845
- const newString = stringValue(parsed.args.newString);
846
- if (oldString === undefined || newString === undefined) {
847
- refuse("the replacement is missing its old or new text");
848
- return;
849
- }
850
- // apply_patch matches whole lines, so a substring edit has to be widened to
851
- // the lines it touches — which requires the file the model is editing.
852
- const absolute = path.resolve(workspaceRoot ?? process.cwd(), filePath);
853
- let source;
854
- try {
855
- // Bounded because this read is synchronous on the Run pump. Cursor's own
856
- // executor uses the same 50 MB threshold before reading a file it is
857
- // about to write.
858
- const size = fs.statSync(absolute).size;
859
- if (size > MAX_EDIT_SOURCE_BYTES) {
860
- refuse(`the target file is ${Math.round(size / 1024 / 1024)} MB, too large to patch`);
1068
+ try {
1069
+ if (requested === "write") {
1070
+ const content = stringValue(parsed.args.content);
1071
+ if (content === undefined) {
1072
+ refuse("no file content was provided");
861
1073
  return;
862
1074
  }
863
- source = fs.readFileSync(absolute, "utf8");
864
- }
865
- catch (e) {
866
- refuse(`the target file could not be read (${e.message})`);
867
- return;
1075
+ // `*** Add File:` overwrites an existing target, so a whole-file write maps
1076
+ // directly without reading the current contents or diffing.
1077
+ patchText = buildAddFilePatch(filePath, content, patchTool);
868
1078
  }
869
- const plan = planSubstringEdit(source, oldString, newString, parsed.args.replaceAll === true);
870
- if (!plan.ok) {
871
- refuse(plan.reason);
872
- return;
1079
+ else {
1080
+ const oldString = stringValue(parsed.args.oldString);
1081
+ const newString = stringValue(parsed.args.newString);
1082
+ if (oldString === undefined || newString === undefined) {
1083
+ refuse("the replacement is missing its old or new text");
1084
+ return;
1085
+ }
1086
+ // A patch matches whole lines, so a substring edit has to be widened to
1087
+ // the lines it touches — which requires the file the model is editing.
1088
+ const absolute = path.resolve(workspaceRoot ?? process.cwd(), filePath);
1089
+ let source;
1090
+ try {
1091
+ // Bounded because this read is synchronous on the Run pump. Cursor's own
1092
+ // executor uses the same 50 MB threshold before reading a file it is
1093
+ // about to write.
1094
+ const stat = fs.statSync(absolute);
1095
+ if (!stat.isFile()) {
1096
+ refuse("the target is not a regular file");
1097
+ return;
1098
+ }
1099
+ const size = stat.size;
1100
+ if (size > MAX_EDIT_SOURCE_BYTES) {
1101
+ refuse(`the target file is ${Math.round(size / 1024 / 1024)} MB, too large to patch`);
1102
+ return;
1103
+ }
1104
+ source = fs.readFileSync(absolute, "utf8");
1105
+ }
1106
+ catch (e) {
1107
+ refuse(`the target file could not be read (${e.message})`);
1108
+ return;
1109
+ }
1110
+ if (patchTool === "patch" && /\r(?:\n|$)/.test(source)) {
1111
+ refuse("the patch tool strips carriage returns from line endings; the source content cannot be preserved");
1112
+ return;
1113
+ }
1114
+ // Preserve native edit semantics rather than Cursor's whole-file wire
1115
+ // representation. OpenCode 1.x normalizes both edit arguments to the
1116
+ // source's newline convention; Cursor may send LF even after reading CRLF.
1117
+ // https://github.com/anomalyco/opencode/blob/v1.19.1/packages/opencode/src/tool/edit.ts
1118
+ const lineEnding = source.includes("\r\n") ? "\r\n" : "\n";
1119
+ const editText = (value) => patchTool === "apply_patch"
1120
+ ? value.replace(/\r\n/g, "\n").replace(/\n/g, lineEnding)
1121
+ : value;
1122
+ const plan = planSubstringEdit(source, editText(oldString), editText(newString), parsed.args.replaceAll === true);
1123
+ if (!plan.ok) {
1124
+ refuse(plan.reason);
1125
+ return;
1126
+ }
1127
+ patchText = buildUpdateFilePatch(filePath, plan.chunks, patchTool);
873
1128
  }
874
- patchText = buildUpdateFilePatch(filePath, plan.chunks);
875
1129
  }
876
- parsed.toolName = APPLY_PATCH_TOOL;
1130
+ catch (error) {
1131
+ refuse(error instanceof Error ? error.message : String(error));
1132
+ return;
1133
+ }
1134
+ parsed.toolName = patchTool;
877
1135
  parsed.args = { patchText };
878
- // apply_patch's output does not carry opencode `write`'s <path> tag, so keep
1136
+ // Patch output does not carry opencode `write`'s <path> tag, so keep
879
1137
  // the requested path for the typed Cursor result.
880
1138
  parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
881
1139
  }
@@ -918,7 +1176,7 @@ export function rejectPartialReadMutation(parsed) {
918
1176
  if (parsed.toolName === "write") {
919
1177
  content = parsed.args.content;
920
1178
  }
921
- else if (parsed.toolName === APPLY_PATCH_TOOL) {
1179
+ else if (isHostPatchTool(parsed.toolName)) {
922
1180
  const patchText = parsed.args.patchText;
923
1181
  if (typeof patchText !== "string" || !patchText.includes("*** Add File:"))
924
1182
  return;
@@ -987,6 +1245,7 @@ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
987
1245
  background_shell_spawn: true,
988
1246
  command: command ?? "",
989
1247
  working_directory: workingDirectory,
1248
+ ...(str(raw.description) ? { description: str(raw.description) } : {}),
990
1249
  },
991
1250
  localError: raw.enable_write_shell_stdin_tool === true
992
1251
  ? "Interactive background shells are not available through OpenCode's bash tool."
@@ -1086,7 +1345,9 @@ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
1086
1345
  ? writeRequestResultMetadata(rawArgs, mapped.args)
1087
1346
  : execVariant === "grep_args"
1088
1347
  ? grepRequestResultMetadata(rawArgs)
1089
- : undefined;
1348
+ : execVariant === "delete_args" || execVariant === "ls_args"
1349
+ ? { path: str(rawArgs.path) ?? "" }
1350
+ : undefined;
1090
1351
  if (resultMetadata
1091
1352
  && resultMetadata.timeout_behavior !== 2
1092
1353
  && typeof resultMetadata.timeout_ms === "number") {
@@ -1129,6 +1390,7 @@ function shellStreamResultMetadata(raw) {
1129
1390
  return {
1130
1391
  shell_stream: true,
1131
1392
  command: str(raw.command) ?? "",
1393
+ ...(str(raw.description) ? { description: str(raw.description) } : {}),
1132
1394
  working_directory: str(raw.working_directory) ?? "",
1133
1395
  timeout_ms: effectiveTimeout,
1134
1396
  timeout_behavior: timeoutBehavior,
@@ -1163,10 +1425,9 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
1163
1425
  // as any other bash tool call.
1164
1426
  if (execVariant === "delete_args") {
1165
1427
  const target = str(cleaned.path) ?? str(cleaned.filePath);
1166
- return {
1167
- toolName: dialect.shellTool,
1168
- args: target ? { command: `rm -f -- ${shellQuote(target)}` } : { command: "true" },
1169
- };
1428
+ return mapCursorArgsToOpencode(dialect.shellTool, {
1429
+ command: target ? `rm -f -- ${shellQuote(target)}` : "true",
1430
+ }, undefined, dialect);
1170
1431
  }
1171
1432
  switch (toolName) {
1172
1433
  case "read": {
@@ -1237,12 +1498,24 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
1237
1498
  const command = str(cleaned.command);
1238
1499
  if (command)
1239
1500
  args.command = command;
1501
+ if (dialect.shellDescription) {
1502
+ const description = str(cleaned.description)
1503
+ ?? (dialect.shellDescription === "required" && command
1504
+ ? `Run: ${command.length > 60 ? `${command.slice(0, 57)}...` : command}`
1505
+ : undefined);
1506
+ if (description)
1507
+ args.description = description;
1508
+ }
1240
1509
  const workdir = str(cleaned.workdir) ?? str(cleaned.working_directory);
1241
1510
  if (workdir)
1242
1511
  args.workdir = workdir;
1243
1512
  const timeout = num(cleaned.timeout);
1244
1513
  if (timeout !== undefined)
1245
1514
  args.timeout = timeout;
1515
+ // The model only sends this when the host's advertised shell schema has
1516
+ // it (OpenCode 2); the host then owns the background job and its notice.
1517
+ if (cleaned.background === true)
1518
+ args.background = true;
1246
1519
  return { toolName: dialect.shellTool, args };
1247
1520
  }
1248
1521
  case "grep": {
@@ -1288,6 +1561,22 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
1288
1561
  args[dialect.skillArgKey] = value;
1289
1562
  return { toolName: "skill", args };
1290
1563
  }
1564
+ case "todowrite": {
1565
+ // OpenCode requires `priority` on every todo item. Cursor TodoWrite /
1566
+ // model MCP calls often omit it; fill host defaults before validation.
1567
+ return {
1568
+ toolName: "todowrite",
1569
+ args: normalizeOpencodeTodoArgs(cleaned),
1570
+ };
1571
+ }
1572
+ case "question": {
1573
+ // OpenCode requires `header` on every question. Model MCP calls often
1574
+ // omit it (AskQuestion bridge already fills it); default before validation.
1575
+ return {
1576
+ toolName: "question",
1577
+ args: normalizeOpencodeQuestionArgs(cleaned),
1578
+ };
1579
+ }
1291
1580
  default:
1292
1581
  return { toolName, args: cleaned };
1293
1582
  }
@@ -1511,6 +1800,9 @@ export function classifyMissingReadTarget(absolutePath) {
1511
1800
  }
1512
1801
  return undefined;
1513
1802
  }
1803
+ return classifyReadTargetStat(absolutePath, stat);
1804
+ }
1805
+ function classifyReadTargetStat(absolutePath, stat) {
1514
1806
  if (stat.isDirectory()) {
1515
1807
  return { invalid_file: { path: absolutePath, reason: "Path is a directory, not a file" } };
1516
1808
  }
@@ -1539,6 +1831,24 @@ export function buildReadRejectionMessages(execId, readResult) {
1539
1831
  buildExecStreamClose(execId),
1540
1832
  ];
1541
1833
  }
1834
+ /**
1835
+ * The leading tool-result images an exec result can carry on a held Run.
1836
+ * Cursor's own read executor answers an image file with its bytes in
1837
+ * `ReadSuccess.data` (#5) and no text; its MCP executor appends each MCP image
1838
+ * as an `McpImageContent` item (#2). Other result shapes have no image field.
1839
+ */
1840
+ export function execResultImages(resultField, images) {
1841
+ if (!images?.length)
1842
+ return [];
1843
+ if (resultField === "read_result")
1844
+ return [images[0]];
1845
+ if (resultField === "mcp_result")
1846
+ return [...images];
1847
+ return [];
1848
+ }
1849
+ function mcpImageItems(images) {
1850
+ return images.map((image) => ({ image: { data: image.data, mime_type: image.mimeType } }));
1851
+ }
1542
1852
  /**
1543
1853
  * Build one or more ExecClientMessage frames for a tool result.
1544
1854
  * Shell replies are a sequence of ShellStream oneofs under the same id —
@@ -1547,20 +1857,28 @@ export function buildReadRejectionMessages(execId, readResult) {
1547
1857
  * shell execs hang on heartbeats forever).
1548
1858
  */
1549
1859
  export function buildExecClientMessages(input) {
1860
+ if (input.error === "")
1861
+ return buildExecClientMessages({ ...input, error: "OpenCode tool execution failed" });
1550
1862
  const resultField = input.resultField || "mcp_result";
1551
1863
  const frames = [];
1552
- if (resultField === "shell_stream") {
1553
- const stdout = groundShellPathText(input.output, shellPathRoot(input.resultMetadata, input.workspaceRoot));
1864
+ const refused = refusedExecResult(input);
1865
+ if (resultField === "shell_stream" && refused) {
1866
+ frames.push(encodeShellStream(input.execId, input.executionTimeMs, refused));
1867
+ }
1868
+ else if (resultField === "shell_stream") {
1554
1869
  // Real clients always emit Start → Stdout/Stderr* → Exit (capture/tests).
1555
1870
  frames.push(encodeShellStream(input.execId, undefined, { start: {} }));
1871
+ // Retain progress even when the host also reports a failure or interruption.
1872
+ // A command may change directories; stdout must reach Cursor unchanged.
1873
+ if (input.output) {
1874
+ frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: input.output } }));
1875
+ }
1556
1876
  if (input.error) {
1557
1877
  frames.push(encodeShellStream(input.execId, undefined, { stderr: { data: input.error } }));
1558
- frames.push(encodeShellStream(input.execId, input.executionTimeMs, { exit: { code: 1, aborted: false } }));
1878
+ frames.push(encodeShellStream(input.execId, input.executionTimeMs, { exit: input.interrupted || input.shellOutcome?.kind === "aborted"
1879
+ ? { code: 0, aborted: true, abort_reason: 1 } : { code: 1, aborted: false } }));
1559
1880
  }
1560
1881
  else {
1561
- if (stdout) {
1562
- frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: stdout } }));
1563
- }
1564
1882
  if (input.shellOutcome?.kind === "backgrounded") {
1565
1883
  frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
1566
1884
  backgrounded: {
@@ -1579,6 +1897,11 @@ export function buildExecClientMessages(input) {
1579
1897
  exit: { code: 0, aborted: true, abort_reason: 2 },
1580
1898
  }));
1581
1899
  }
1900
+ else if (input.shellOutcome?.kind === "aborted") {
1901
+ frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
1902
+ exit: { code: 0, aborted: true, abort_reason: 1 },
1903
+ }));
1904
+ }
1582
1905
  else {
1583
1906
  const exitCode = input.shellOutcome?.kind === "exit"
1584
1907
  ? Math.max(0, Math.min(0xffff_ffff, input.shellOutcome.code))
@@ -1594,7 +1917,14 @@ export function buildExecClientMessages(input) {
1594
1917
  id: input.execId,
1595
1918
  local_execution_time_ms: input.executionTimeMs ?? 0,
1596
1919
  };
1597
- clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata, input.shellOutcome, input.workspaceRoot);
1920
+ clientMsg[resultField] = refused ?? buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata, input.shellOutcome, input.workspaceRoot, execResultImages(resultField, input.images));
1921
+ if ((input.interrupted || input.shellOutcome?.kind === "aborted") && resultField === "shell_result") {
1922
+ const failure = clientMsg[resultField].failure;
1923
+ if (failure) {
1924
+ failure.aborted = true;
1925
+ failure.exit_code = 0;
1926
+ }
1927
+ }
1598
1928
  frames.push(encodeMessage("AgentClientMessage", {
1599
1929
  exec_client_message: clientMsg,
1600
1930
  }));
@@ -1603,6 +1933,47 @@ export function buildExecClientMessages(input) {
1603
1933
  frames.push(buildExecStreamClose(input.execId));
1604
1934
  return frames;
1605
1935
  }
1936
+ /**
1937
+ * Attach host notes to an exec result as Cursor CLI postToolUse hook context:
1938
+ * `ExecClientMessage.hook_additional_contexts` (#45) on a unary result, or a
1939
+ * `ShellStream.hook_context` (#8) event after the stream's own events and
1940
+ * before `stream_close` (CLI agent-exec hook wrapper). Cursor renders it as a
1941
+ * system reminder on that tool result, so it never mixes into file content.
1942
+ * Returns undefined when the frames carry no exec result (bridged
1943
+ * interaction replies have no such field).
1944
+ */
1945
+ export function attachHookAdditionalContexts(frames, contexts) {
1946
+ const root = getMessageTypes();
1947
+ const type = root.lookupType("AgentClientMessage");
1948
+ const contextType = root.lookupType("HookAdditionalContext");
1949
+ let shellStreamAt = -1;
1950
+ let shellStreamId = 0;
1951
+ for (let index = 0; index < frames.length; index++) {
1952
+ const message = type.decode(frames[index]);
1953
+ const exec = message.exec_client_message;
1954
+ if (!exec?.result)
1955
+ continue;
1956
+ if (exec.result === "shell_stream") {
1957
+ shellStreamAt = index;
1958
+ shellStreamId = exec.id ?? 0;
1959
+ continue;
1960
+ }
1961
+ exec.hook_additional_contexts = contexts.map((context) => contextType.fromObject(context));
1962
+ const out = [...frames];
1963
+ out[index] = type.encode(message).finish();
1964
+ return out;
1965
+ }
1966
+ if (shellStreamAt === -1)
1967
+ return undefined;
1968
+ const out = [...frames];
1969
+ out.splice(shellStreamAt + 1, 0, encodeMessage("AgentClientMessage", {
1970
+ exec_client_message: {
1971
+ id: shellStreamId,
1972
+ shell_stream: { hook_context: { hook_additional_contexts: [...contexts] } },
1973
+ },
1974
+ }));
1975
+ return out;
1976
+ }
1606
1977
  /** ACM #5 exec_client_control_message { stream_close { id } }. */
1607
1978
  export function buildExecStreamClose(execId) {
1608
1979
  return encodeMessage("AgentClientMessage", {
@@ -1611,6 +1982,38 @@ export function buildExecStreamClose(execId) {
1611
1982
  },
1612
1983
  });
1613
1984
  }
1985
+ /** Refusal oneofs verified against the extracted agent.v1 protocol. */
1986
+ function refusedExecResult(input) {
1987
+ if (!input.refusal || !input.error)
1988
+ return undefined;
1989
+ const denied = input.refusal === "permission_denied";
1990
+ const reason = input.error;
1991
+ const requested = str(input.resultMetadata?.path) ?? "";
1992
+ const filePath = requested && input.workspaceRoot && !isUriReadTarget(requested)
1993
+ ? resolveReadTargetPath(requested, input.workspaceRoot) : requested;
1994
+ const command = str(input.resultMetadata?.command) ?? "";
1995
+ const working_directory = str(input.resultMetadata?.working_directory) ?? "";
1996
+ switch (input.resultField) {
1997
+ case "shell_stream":
1998
+ case "shell_result":
1999
+ case "background_shell_spawn_result":
2000
+ return denied ? { permission_denied: { command, working_directory, error: reason } }
2001
+ : { rejected: { command, working_directory, reason } };
2002
+ case "write_result":
2003
+ return denied ? { permission_denied: { path: filePath, directory: filePath ? path.dirname(filePath) : "", operation: "write", error: reason, is_readonly: false } }
2004
+ : { rejected: { path: filePath, reason } };
2005
+ case "read_result":
2006
+ return denied ? { permission_denied: { path: filePath } } : { rejected: { path: filePath, reason } };
2007
+ case "ls_result": return { rejected: { path: filePath, reason } };
2008
+ case "delete_result":
2009
+ return denied ? { permission_denied: { path: filePath, client_visible_error: reason, is_readonly: false } }
2010
+ : { rejected: { path: filePath, reason } };
2011
+ case "mcp_result":
2012
+ return denied ? { permission_denied: { error: reason, is_readonly: false } }
2013
+ : { rejected: { reason, is_readonly: false } };
2014
+ default: return undefined;
2015
+ }
2016
+ }
1614
2017
  export function buildUnsupportedExecDeny(input) {
1615
2018
  const { execId, variant, reason } = input;
1616
2019
  const resultName = variant.resultName;
@@ -2006,21 +2409,6 @@ function splitForeignAbsolute(filePath, sep) {
2006
2409
  frozen: 0,
2007
2410
  };
2008
2411
  }
2009
- function isRelativePathToken(token) {
2010
- if (!token || isAbsoluteToolPath(token))
2011
- return false;
2012
- // A slash alone does not make arbitrary shell output a path. In particular,
2013
- // compact JSON, quoted strings, package ids, and shell syntax must remain
2014
- // byte-for-byte model-visible rather than being prefixed with the workspace.
2015
- if (/[\0"'`{}\[\]<>|;]/.test(token) || token.startsWith("@"))
2016
- return false;
2017
- if (token.startsWith("./")
2018
- || token.startsWith("../")
2019
- || token.startsWith(".\\")
2020
- || token.startsWith("..\\"))
2021
- return true;
2022
- return token.includes("/") || (path.sep === "\\" && token.includes("\\"));
2023
- }
2024
2412
  function resolveListedEntry(directory, entry) {
2025
2413
  if (!entry || isAbsoluteToolPath(entry))
2026
2414
  return entry;
@@ -2236,46 +2624,6 @@ function rewriteSearchPathLine(line, workspaceRoot) {
2236
2624
  return line;
2237
2625
  return resolveToolPath(line, workspaceRoot);
2238
2626
  }
2239
- /**
2240
- * Shell stdout is mixed prose. Rewrite only tokens that are clearly relative
2241
- * paths, including `file:line` and `file:line:col`. Leave sentences, URLs, and
2242
- * status words alone.
2243
- */
2244
- function groundShellPathText(output, root) {
2245
- if (!root || !output)
2246
- return output;
2247
- return normalizeToolText(output).split("\n").map((line) => rewriteShellPathLine(line, root)).join("\n");
2248
- }
2249
- function shellPathRoot(resultMetadata, workspaceRoot) {
2250
- const workingDirectory = str(resultMetadata?.working_directory)?.trim();
2251
- if (!workingDirectory)
2252
- return workspaceRoot;
2253
- if (isAbsoluteToolPath(workingDirectory))
2254
- return workingDirectory;
2255
- return workspaceRoot ? joinToolPath(workspaceRoot, workingDirectory) : undefined;
2256
- }
2257
- function rewriteShellPathLine(line, root) {
2258
- if (!line || line.startsWith(" ") || line.startsWith("\t"))
2259
- return line;
2260
- const trimmed = line.trimEnd();
2261
- if (trimmed.includes("://") || trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
2262
- return line;
2263
- }
2264
- const located = /^(.+?):(\d+)(?::(\d+))?$/.exec(trimmed);
2265
- if (located && located[1] && isRelativePathToken(located[1])) {
2266
- const suffix = located[3] !== undefined ? `:${located[2]}:${located[3]}` : `:${located[2]}`;
2267
- return `${resolveToolPath(located[1], root)}${suffix}`;
2268
- }
2269
- if (/\s/.test(trimmed))
2270
- return line;
2271
- const header = /^(.*):$/.exec(trimmed);
2272
- if (header && header[1] && isRelativePathToken(header[1])) {
2273
- return `${resolveToolPath(header[1], root)}:`;
2274
- }
2275
- if (isRelativePathToken(trimmed))
2276
- return resolveToolPath(trimmed, root);
2277
- return line;
2278
- }
2279
2627
  const OPENCODE_GREP_LINE = /^[ \t]+Line (\d+):[ \t]?(.*)$/;
2280
2628
  /**
2281
2629
  * OpenCode 1.x and 2.0 both render grep as `Found N matches`, a `path:` header,
@@ -2458,12 +2806,36 @@ export function unwrapReadOutput(output) {
2458
2806
  // than the envelope (the envelope is exactly what Cursor echoes into writes).
2459
2807
  return raw.join("\n");
2460
2808
  }
2809
+ /**
2810
+ * OpenCode's read tool appends newly discovered instructions after the
2811
+ * envelope's unnumbered `</content>`, in one `<system-reminder>` block.
2812
+ * The numbered-body parser stops before that block, so callers must lift it
2813
+ * out before the file text is encoded.
2814
+ */
2815
+ export function extractOpenCodeReadInstruction(output) {
2816
+ if (!output.includes("<content>") || !output.includes("<system-reminder>"))
2817
+ return undefined;
2818
+ const contentHeaderIdx = output.indexOf("<content>");
2819
+ const header = output.slice(0, contentHeaderIdx);
2820
+ if (!header.includes("<path>") || !header.includes("<type>file</type>"))
2821
+ return undefined;
2822
+ const rest = output.slice(contentHeaderIdx + "<content>".length);
2823
+ const closeAt = rest.search(/(?:^|\n)<\/content>(?:\n|$)/);
2824
+ if (closeAt === -1)
2825
+ return undefined;
2826
+ const afterClose = rest.slice(closeAt).replace(/^(?:\n)?<\/content>/, "");
2827
+ const match = /^\s*<system-reminder>\n([\s\S]*?)\n<\/system-reminder>/.exec(afterClose);
2828
+ const body = match?.[1]?.trim();
2829
+ return body ? body : undefined;
2830
+ }
2461
2831
  /**
2462
2832
  * Map OpenCode tool text into the agent.v1 result oneof for each exec variant.
2463
2833
  * OpenCode returns free-form text; we wrap it in the minimal success shape the
2464
2834
  * server accepts (verified against agent.v1 wire captures).
2465
2835
  */
2466
- export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot) {
2836
+ export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot, images = []) {
2837
+ if (error === "")
2838
+ error = "OpenCode tool execution failed";
2467
2839
  // Prefer the session workspace; never advertise the host process cwd (daemon
2468
2840
  // often starts in $HOME) as the path Cursor shows the model for glob/ls.
2469
2841
  const trimmedRoot = typeof workspaceRoot === "string" ? workspaceRoot.trim() : "";
@@ -2483,6 +2855,18 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2483
2855
  const readPath = resolveToolPath(rawPath, resultRoot);
2484
2856
  if (error)
2485
2857
  return { error: { path: readPath, error } };
2858
+ const image = images[0];
2859
+ if (image) {
2860
+ return {
2861
+ success: {
2862
+ path: readPath,
2863
+ data: image.data,
2864
+ total_lines: 0,
2865
+ file_size: readFileSize(readPath) || image.data.length,
2866
+ truncated: false,
2867
+ },
2868
+ };
2869
+ }
2486
2870
  // The host may have resolved an alternate spelling after the request
2487
2871
  // (for example a Unicode-space filename). Use the result's own path for
2488
2872
  // local stat/newline recovery while preserving the requested path in the
@@ -2572,7 +2956,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2572
2956
  };
2573
2957
  }
2574
2958
  case "write_result": {
2575
- // Prefer the path recorded from WriteArgs / a remapped apply_patch write.
2959
+ // Prefer the path recorded from WriteArgs / a remapped patch write.
2576
2960
  // Host write output is not a `<path>` envelope, and Cursor's GetMcpTools
2577
2961
  // spill copies WriteSuccess.path into the model-visible filePath.
2578
2962
  const remappedPath = str(resultMetadata?.path);
@@ -2603,16 +2987,15 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2603
2987
  case "shell_result": {
2604
2988
  const command = str(resultMetadata?.command) ?? "";
2605
2989
  const workingDirectory = str(resultMetadata?.working_directory) ?? "";
2606
- const stdout = groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot));
2607
- if (error) {
2990
+ if (error || shellOutcome?.kind === "aborted") {
2608
2991
  return {
2609
2992
  failure: {
2610
2993
  command,
2611
2994
  working_directory: workingDirectory,
2612
- exit_code: 1,
2613
- stdout: stdout || "",
2614
- stderr: error,
2615
- aborted: false,
2995
+ exit_code: shellOutcome?.kind === "aborted" ? 0 : 1,
2996
+ stdout: output || "",
2997
+ stderr: error || "Shell execution interrupted",
2998
+ aborted: shellOutcome?.kind === "aborted",
2616
2999
  },
2617
3000
  };
2618
3001
  }
@@ -2631,7 +3014,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2631
3014
  command: shellOutcome.command || command,
2632
3015
  working_directory: shellOutcome.workingDirectory || workingDirectory,
2633
3016
  exit_code: 0,
2634
- stdout: stdout,
3017
+ stdout: output,
2635
3018
  shell_id: shellOutcome.shellId,
2636
3019
  pid: shellOutcome.pid,
2637
3020
  ms_to_wait: shellOutcome.msToWait,
@@ -2649,14 +3032,11 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2649
3032
  command,
2650
3033
  working_directory: workingDirectory,
2651
3034
  exit_code: exitCode,
2652
- stdout: stdout,
3035
+ stdout: output,
2653
3036
  },
2654
3037
  };
2655
3038
  }
2656
3039
  case "pi_bash_result":
2657
- if (error)
2658
- return { error: { error } };
2659
- return { success: { output: groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot)) } };
2660
3040
  case "pi_edit_result":
2661
3041
  if (error)
2662
3042
  return { error: { error } };
@@ -2674,13 +3054,13 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2674
3054
  }
2675
3055
  case "delete_result":
2676
3056
  if (error)
2677
- return { error: { path: "", error } };
2678
- return { success: { path: "", deleted_file: "" } };
3057
+ return { error: { path: resolveToolPath(str(resultMetadata?.path) ?? "", resultRoot), error } };
3058
+ return { success: { path: resolveToolPath(str(resultMetadata?.path) ?? "", resultRoot), deleted_file: "" } };
2679
3059
  case "background_shell_spawn_result": {
2680
3060
  const command = str(resultMetadata?.command) ?? "";
2681
3061
  const workingDirectory = str(resultMetadata?.working_directory) ?? "";
2682
- if (error)
2683
- return { error: { command, working_directory: workingDirectory, error } };
3062
+ if (error || shellOutcome?.kind === "aborted")
3063
+ return { error: { command, working_directory: workingDirectory, error: error || "Shell execution interrupted" } };
2684
3064
  // Prefer the structured outcome captured by the plugin after-hook; markers
2685
3065
  // are already stripped from the stored/rendered OpenCode output by then.
2686
3066
  if (shellOutcome?.kind === "backgrounded") {
@@ -2694,8 +3074,10 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2694
3074
  };
2695
3075
  }
2696
3076
  // Fallback when no plugin hook ran: parse the private spawn marker inline.
2697
- const match = new RegExp(`${BACKGROUND_SHELL_MARKER}(\\d+):([^\\r\\n]+)`).exec(output);
2698
- const pid = match ? Number(match[1]) : 0;
3077
+ // A managed spawn reports `shell-id:pid:file`; the unmanaged wrapper `pid:file`.
3078
+ const match = new RegExp(`${BACKGROUND_SHELL_MARKER}(\\d+):(?:(\\d+):)?([^\\r\\n]+)`).exec(output);
3079
+ const shellId = match ? Number(match[1]) : 0;
3080
+ const pid = match ? Number(match[2] || match[1]) : 0;
2699
3081
  if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffff_ffff) {
2700
3082
  return {
2701
3083
  error: {
@@ -2707,7 +3089,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2707
3089
  }
2708
3090
  return {
2709
3091
  success: {
2710
- shell_id: pid,
3092
+ shell_id: shellId,
2711
3093
  command,
2712
3094
  working_directory: workingDirectory,
2713
3095
  pid,
@@ -2716,7 +3098,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2716
3098
  }
2717
3099
  case "ls_result": {
2718
3100
  if (error)
2719
- return { error: { path: "", error } };
3101
+ return { error: { path: resolveToolPath(str(resultMetadata?.path) ?? "", resultRoot), error } };
2720
3102
  const listing = parseOpenCode2DirectoryListing(output, resultRoot);
2721
3103
  const rootPath = listing?.directory || resultRoot || "";
2722
3104
  const entries = listing ? listing.entries : extractGroundedPaths(output, resultRoot);
@@ -2750,18 +3132,20 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2750
3132
  if (toolName === "grep" || toolName === "glob") {
2751
3133
  return {
2752
3134
  success: {
2753
- content: [{ text: { text: groundSearchOutput(output, resultRoot) } }],
3135
+ content: [{ text: { text: groundSearchOutput(output, resultRoot) } }, ...mcpImageItems(images)],
2754
3136
  is_error: false,
2755
3137
  },
2756
3138
  };
2757
3139
  }
2758
3140
  if (toolName !== "read") {
2759
- return { success: { content: [{ text: { text: output } }], is_error: false } };
3141
+ return {
3142
+ success: { content: [{ text: { text: output } }, ...mcpImageItems(images)], is_error: false },
3143
+ };
2760
3144
  }
2761
3145
  const listing = parseOpenCode2DirectoryListing(output, resultRoot);
2762
3146
  if (listing) {
2763
3147
  return {
2764
- success: { content: [{ text: { text: listing.text } }], is_error: false },
3148
+ success: { content: [{ text: { text: listing.text } }, ...mcpImageItems(images)], is_error: false },
2765
3149
  };
2766
3150
  }
2767
3151
  // Carry the truncation notice as its own content item: the file content
@@ -2775,6 +3159,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2775
3159
  content: [
2776
3160
  { text: { text: unwrapReadOutput(output) } },
2777
3161
  ...notices.map((notice) => ({ text: { text: notice } })),
3162
+ ...mcpImageItems(images),
2778
3163
  ],
2779
3164
  is_error: false,
2780
3165
  },
@@ -3226,7 +3611,14 @@ export function buildMcpStateResult(execId, args, toolDescriptors) {
3226
3611
  }
3227
3612
  list.push({
3228
3613
  name: stringValue(tool.name) ?? `${server}-${toolName}`,
3229
- description: stringValue(tool.description) ?? "",
3614
+ // GetDynamicTools exposes the inner schema without the outer call identity.
3615
+ // Keep the exact identity in the definition, including truncated searches.
3616
+ description: `CallDynamicTool identity: ${JSON.stringify({ namespace: server, toolName })}. `
3617
+ + `Complete outer envelope: ${JSON.stringify({ namespace: server, toolName, arguments: {} })}. `
3618
+ + "Replace arguments with this inputSchema's object; keep both identity fields outside it. "
3619
+ + "Write namespace and toolName FIRST, then arguments containing this inputSchema's object. "
3620
+ + "All three outer fields are required on every invocation.\n\n"
3621
+ + (stringValue(tool.description) ?? ""),
3230
3622
  input_schema: tool.input_schema,
3231
3623
  provider_identifier: server,
3232
3624
  tool_name: toolName,