cursor-opencode-provider 0.8.1 → 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 (96) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +204 -37
  3. package/SECURITY.md +20 -8
  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 +1 -0
  14. package/dist/context/build.js +32 -5
  15. package/dist/context/env.js +27 -3
  16. package/dist/context/epoch.js +9 -5
  17. package/dist/context/git.d.ts +5 -2
  18. package/dist/context/git.js +17 -7
  19. package/dist/context/paths.d.ts +1 -1
  20. package/dist/context/paths.js +7 -4
  21. package/dist/debug.js +13 -2
  22. package/dist/early-tool-rows.d.ts +60 -0
  23. package/dist/early-tool-rows.js +96 -0
  24. package/dist/errors.d.ts +49 -7
  25. package/dist/errors.js +163 -8
  26. package/dist/host-interrupt.d.ts +12 -0
  27. package/dist/host-interrupt.js +18 -0
  28. package/dist/host-steer.d.ts +71 -0
  29. package/dist/host-steer.js +232 -0
  30. package/dist/image-input.js +4 -3
  31. package/dist/index.d.ts +2 -2
  32. package/dist/language-model.d.ts +114 -23
  33. package/dist/language-model.js +2290 -469
  34. package/dist/lost-request.d.ts +39 -0
  35. package/dist/lost-request.js +111 -0
  36. package/dist/model-config.d.ts +3 -1
  37. package/dist/model-config.js +50 -29
  38. package/dist/models.d.ts +26 -3
  39. package/dist/models.js +101 -45
  40. package/dist/opencode2/tool-results.d.ts +18 -0
  41. package/dist/opencode2/tool-results.js +66 -0
  42. package/dist/plugin-opencode2.js +185 -25
  43. package/dist/plugin.js +28 -5
  44. package/dist/pricing-data.d.ts +26 -2
  45. package/dist/pricing-data.js +26 -2
  46. package/dist/pricing.d.ts +1 -1
  47. package/dist/pricing.js +7 -4
  48. package/dist/process-shared.d.ts +14 -0
  49. package/dist/process-shared.js +18 -0
  50. package/dist/protocol/apply-patch.d.ts +25 -13
  51. package/dist/protocol/apply-patch.js +79 -30
  52. package/dist/protocol/blob-store.d.ts +5 -1
  53. package/dist/protocol/blob-store.js +10 -6
  54. package/dist/protocol/client-version.js +6 -4
  55. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  56. package/dist/protocol/conversation-cache-header.js +149 -0
  57. package/dist/protocol/conversation-persistence.d.ts +27 -1
  58. package/dist/protocol/conversation-persistence.js +628 -126
  59. package/dist/protocol/conversation-state.d.ts +8 -1
  60. package/dist/protocol/conversation-state.js +28 -7
  61. package/dist/protocol/host-notes.d.ts +31 -0
  62. package/dist/protocol/host-notes.js +85 -0
  63. package/dist/protocol/messages.js +91 -8
  64. package/dist/protocol/request.d.ts +35 -19
  65. package/dist/protocol/request.js +53 -28
  66. package/dist/protocol/sent-history-images.d.ts +49 -0
  67. package/dist/protocol/sent-history-images.js +102 -0
  68. package/dist/protocol/tool-call-bridge.d.ts +2 -0
  69. package/dist/protocol/tool-call-bridge.js +10 -0
  70. package/dist/protocol/tools.d.ts +74 -8
  71. package/dist/protocol/tools.js +431 -141
  72. package/dist/protocol/turn-provenance.d.ts +19 -4
  73. package/dist/protocol/turn-provenance.js +77 -20
  74. package/dist/replay-safety.d.ts +16 -4
  75. package/dist/replay-safety.js +34 -25
  76. package/dist/retry-budget.d.ts +21 -0
  77. package/dist/retry-budget.js +94 -0
  78. package/dist/runtime-files.d.ts +23 -0
  79. package/dist/runtime-files.js +581 -0
  80. package/dist/session.d.ts +94 -9
  81. package/dist/session.js +153 -34
  82. package/dist/shared.d.ts +1 -1
  83. package/dist/shared.js +1 -1
  84. package/dist/shell-timeout.d.ts +54 -10
  85. package/dist/shell-timeout.js +362 -92
  86. package/dist/stream-write.d.ts +20 -0
  87. package/dist/stream-write.js +87 -0
  88. package/dist/terminal-runner.d.ts +15 -0
  89. package/dist/terminal-runner.js +528 -0
  90. package/dist/transport/connect.d.ts +8 -1
  91. package/dist/transport/connect.js +103 -23
  92. package/dist/usage.d.ts +37 -17
  93. package/dist/usage.js +63 -29
  94. package/package.json +3 -2
  95. package/dist/protocol/thinking.d.ts +0 -15
  96. package/dist/protocol/thinking.js +0 -17
@@ -1,13 +1,13 @@
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
12
  import { normalizeOpencodeQuestionArgs } from "./ask-question.js";
13
13
  import { normalizeOpencodeTodoArgs } from "../todo-store.js";
@@ -667,6 +667,46 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
667
667
  }
668
668
  /** Matches Cursor's own pre-write read threshold (`local-exec` 52428800). */
669
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
+ }
670
710
  /**
671
711
  * Complete the private read inside Cursor's legacy edit transaction without
672
712
  * routing it through OpenCode's user-facing `read` tool. The latter caps every
@@ -706,6 +746,60 @@ export function buildCompleteEditReadMessages(execId, sourcePath, resultPath = s
706
746
  return undefined;
707
747
  }
708
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
+ }
709
803
  /** Return the number of non-overlapping occurrences, stopping after ambiguity. */
710
804
  function replacementOccurrenceCount(source, needle) {
711
805
  if (!needle)
@@ -778,6 +872,41 @@ function planWholeFileEdit(source, target) {
778
872
  }
779
873
  return result;
780
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
+ }
781
910
  /**
782
911
  * Preserve a legacy Cursor edit's intent when its internal executor follows
783
912
  * `edit_tool_call` with a whole-file `write_args` request.
@@ -789,7 +918,7 @@ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames,
789
918
  if (parsed.resultField !== "write_result" || parsed.toolName !== "write")
790
919
  return false;
791
920
  const advertised = new Set(advertisedToolNames);
792
- if (!advertised.has("edit") && !advertised.has(APPLY_PATCH_TOOL))
921
+ if (!advertised.has("edit") && !hostPatchTool(advertised))
793
922
  return false;
794
923
  const filePath = opencodePathArg(parsed.args);
795
924
  const content = stringValue(parsed.args.content);
@@ -828,93 +957,183 @@ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames,
828
957
  parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
829
958
  return true;
830
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
+ }
831
1021
  /**
832
- * 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
833
1023
  * edit-tool family out from under us.
834
1024
  *
835
- * OpenCode 1.x removes `edit` and `write` from the catalog for GPT models and
836
- * advertises `apply_patch` in their place — see the module comment in
837
- * `./apply-patch.ts` for the upstream reasoning and citations. Cursor keeps
838
- * using its native write/edit exec channel regardless, so without this the
839
- * request is refused as an unavailable tool and the model loses the ability to
840
- * 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.
841
1031
  *
842
1032
  * Keyed purely off the advertised set: this is inert whenever the host offers
843
1033
  * `write`/`edit` normally, and equally inert when it offers neither those nor
844
- * `apply_patch` (the caller's unavailable-tool rejection still applies).
1034
+ * a patch tool (the caller's unavailable-tool rejection still applies).
845
1035
  */
846
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
+ }
847
1043
  if (parsed.toolName !== "write" && parsed.toolName !== "edit")
848
1044
  return;
849
- // `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,
850
1046
  // so it must stay a write and reach the byte-preserving path — converting it
851
1047
  // here would drop the bytes and report a patch-translation failure instead of
852
1048
  // the image Cursor asked to save.
853
1049
  if (binaryWritePayload(parsed))
854
1050
  return;
855
- const advertised = new Set(advertisedToolNames);
856
- if (advertised.has(parsed.toolName) || !advertised.has(APPLY_PATCH_TOOL))
1051
+ const patchTool = hostPatchTool(advertised);
1052
+ if (advertised.has(parsed.toolName) || !patchTool)
857
1053
  return;
858
1054
  const requested = parsed.toolName;
859
1055
  const filePath = opencodePathArg(parsed.args);
860
1056
  const refuse = (reason) => {
861
- parsed.toolName = APPLY_PATCH_TOOL;
1057
+ parsed.toolName = patchTool;
862
1058
  parsed.args = {};
863
1059
  parsed.localError =
864
- `Cursor ${requested} request cannot be expressed as an apply_patch call: ${reason}. ` +
865
- "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.`;
866
1062
  };
867
1063
  if (!filePath) {
868
1064
  refuse("no target path was provided");
869
1065
  return;
870
1066
  }
871
1067
  let patchText;
872
- if (requested === "write") {
873
- const content = stringValue(parsed.args.content);
874
- if (content === undefined) {
875
- refuse("no file content was provided");
876
- return;
877
- }
878
- // `*** Add File:` overwrites an existing target, so a whole-file write maps
879
- // directly without reading the current contents or diffing.
880
- patchText = buildAddFilePatch(filePath, content);
881
- }
882
- else {
883
- const oldString = stringValue(parsed.args.oldString);
884
- const newString = stringValue(parsed.args.newString);
885
- if (oldString === undefined || newString === undefined) {
886
- refuse("the replacement is missing its old or new text");
887
- return;
888
- }
889
- // apply_patch matches whole lines, so a substring edit has to be widened to
890
- // the lines it touches — which requires the file the model is editing.
891
- const absolute = path.resolve(workspaceRoot ?? process.cwd(), filePath);
892
- let source;
893
- try {
894
- // Bounded because this read is synchronous on the Run pump. Cursor's own
895
- // executor uses the same 50 MB threshold before reading a file it is
896
- // about to write.
897
- const size = fs.statSync(absolute).size;
898
- if (size > MAX_EDIT_SOURCE_BYTES) {
899
- 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");
900
1073
  return;
901
1074
  }
902
- source = fs.readFileSync(absolute, "utf8");
903
- }
904
- catch (e) {
905
- refuse(`the target file could not be read (${e.message})`);
906
- 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);
907
1078
  }
908
- const plan = planSubstringEdit(source, oldString, newString, parsed.args.replaceAll === true);
909
- if (!plan.ok) {
910
- refuse(plan.reason);
911
- 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);
912
1128
  }
913
- patchText = buildUpdateFilePatch(filePath, plan.chunks);
914
1129
  }
915
- parsed.toolName = APPLY_PATCH_TOOL;
1130
+ catch (error) {
1131
+ refuse(error instanceof Error ? error.message : String(error));
1132
+ return;
1133
+ }
1134
+ parsed.toolName = patchTool;
916
1135
  parsed.args = { patchText };
917
- // 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
918
1137
  // the requested path for the typed Cursor result.
919
1138
  parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
920
1139
  }
@@ -957,7 +1176,7 @@ export function rejectPartialReadMutation(parsed) {
957
1176
  if (parsed.toolName === "write") {
958
1177
  content = parsed.args.content;
959
1178
  }
960
- else if (parsed.toolName === APPLY_PATCH_TOOL) {
1179
+ else if (isHostPatchTool(parsed.toolName)) {
961
1180
  const patchText = parsed.args.patchText;
962
1181
  if (typeof patchText !== "string" || !patchText.includes("*** Add File:"))
963
1182
  return;
@@ -1026,6 +1245,7 @@ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
1026
1245
  background_shell_spawn: true,
1027
1246
  command: command ?? "",
1028
1247
  working_directory: workingDirectory,
1248
+ ...(str(raw.description) ? { description: str(raw.description) } : {}),
1029
1249
  },
1030
1250
  localError: raw.enable_write_shell_stdin_tool === true
1031
1251
  ? "Interactive background shells are not available through OpenCode's bash tool."
@@ -1125,7 +1345,9 @@ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
1125
1345
  ? writeRequestResultMetadata(rawArgs, mapped.args)
1126
1346
  : execVariant === "grep_args"
1127
1347
  ? grepRequestResultMetadata(rawArgs)
1128
- : undefined;
1348
+ : execVariant === "delete_args" || execVariant === "ls_args"
1349
+ ? { path: str(rawArgs.path) ?? "" }
1350
+ : undefined;
1129
1351
  if (resultMetadata
1130
1352
  && resultMetadata.timeout_behavior !== 2
1131
1353
  && typeof resultMetadata.timeout_ms === "number") {
@@ -1168,6 +1390,7 @@ function shellStreamResultMetadata(raw) {
1168
1390
  return {
1169
1391
  shell_stream: true,
1170
1392
  command: str(raw.command) ?? "",
1393
+ ...(str(raw.description) ? { description: str(raw.description) } : {}),
1171
1394
  working_directory: str(raw.working_directory) ?? "",
1172
1395
  timeout_ms: effectiveTimeout,
1173
1396
  timeout_behavior: timeoutBehavior,
@@ -1289,6 +1512,10 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
1289
1512
  const timeout = num(cleaned.timeout);
1290
1513
  if (timeout !== undefined)
1291
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;
1292
1519
  return { toolName: dialect.shellTool, args };
1293
1520
  }
1294
1521
  case "grep": {
@@ -1573,6 +1800,9 @@ export function classifyMissingReadTarget(absolutePath) {
1573
1800
  }
1574
1801
  return undefined;
1575
1802
  }
1803
+ return classifyReadTargetStat(absolutePath, stat);
1804
+ }
1805
+ function classifyReadTargetStat(absolutePath, stat) {
1576
1806
  if (stat.isDirectory()) {
1577
1807
  return { invalid_file: { path: absolutePath, reason: "Path is a directory, not a file" } };
1578
1808
  }
@@ -1627,20 +1857,28 @@ function mcpImageItems(images) {
1627
1857
  * shell execs hang on heartbeats forever).
1628
1858
  */
1629
1859
  export function buildExecClientMessages(input) {
1860
+ if (input.error === "")
1861
+ return buildExecClientMessages({ ...input, error: "OpenCode tool execution failed" });
1630
1862
  const resultField = input.resultField || "mcp_result";
1631
1863
  const frames = [];
1632
- if (resultField === "shell_stream") {
1633
- 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") {
1634
1869
  // Real clients always emit Start → Stdout/Stderr* → Exit (capture/tests).
1635
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
+ }
1636
1876
  if (input.error) {
1637
1877
  frames.push(encodeShellStream(input.execId, undefined, { stderr: { data: input.error } }));
1638
- 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 } }));
1639
1880
  }
1640
1881
  else {
1641
- if (stdout) {
1642
- frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: stdout } }));
1643
- }
1644
1882
  if (input.shellOutcome?.kind === "backgrounded") {
1645
1883
  frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
1646
1884
  backgrounded: {
@@ -1659,6 +1897,11 @@ export function buildExecClientMessages(input) {
1659
1897
  exit: { code: 0, aborted: true, abort_reason: 2 },
1660
1898
  }));
1661
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
+ }
1662
1905
  else {
1663
1906
  const exitCode = input.shellOutcome?.kind === "exit"
1664
1907
  ? Math.max(0, Math.min(0xffff_ffff, input.shellOutcome.code))
@@ -1674,7 +1917,14 @@ export function buildExecClientMessages(input) {
1674
1917
  id: input.execId,
1675
1918
  local_execution_time_ms: input.executionTimeMs ?? 0,
1676
1919
  };
1677
- clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata, input.shellOutcome, input.workspaceRoot, execResultImages(resultField, input.images));
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
+ }
1678
1928
  frames.push(encodeMessage("AgentClientMessage", {
1679
1929
  exec_client_message: clientMsg,
1680
1930
  }));
@@ -1683,6 +1933,47 @@ export function buildExecClientMessages(input) {
1683
1933
  frames.push(buildExecStreamClose(input.execId));
1684
1934
  return frames;
1685
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
+ }
1686
1977
  /** ACM #5 exec_client_control_message { stream_close { id } }. */
1687
1978
  export function buildExecStreamClose(execId) {
1688
1979
  return encodeMessage("AgentClientMessage", {
@@ -1691,6 +1982,38 @@ export function buildExecStreamClose(execId) {
1691
1982
  },
1692
1983
  });
1693
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
+ }
1694
2017
  export function buildUnsupportedExecDeny(input) {
1695
2018
  const { execId, variant, reason } = input;
1696
2019
  const resultName = variant.resultName;
@@ -2086,21 +2409,6 @@ function splitForeignAbsolute(filePath, sep) {
2086
2409
  frozen: 0,
2087
2410
  };
2088
2411
  }
2089
- function isRelativePathToken(token) {
2090
- if (!token || isAbsoluteToolPath(token))
2091
- return false;
2092
- // A slash alone does not make arbitrary shell output a path. In particular,
2093
- // compact JSON, quoted strings, package ids, and shell syntax must remain
2094
- // byte-for-byte model-visible rather than being prefixed with the workspace.
2095
- if (/[\0"'`{}\[\]<>|;]/.test(token) || token.startsWith("@"))
2096
- return false;
2097
- if (token.startsWith("./")
2098
- || token.startsWith("../")
2099
- || token.startsWith(".\\")
2100
- || token.startsWith("..\\"))
2101
- return true;
2102
- return token.includes("/") || (path.sep === "\\" && token.includes("\\"));
2103
- }
2104
2412
  function resolveListedEntry(directory, entry) {
2105
2413
  if (!entry || isAbsoluteToolPath(entry))
2106
2414
  return entry;
@@ -2316,46 +2624,6 @@ function rewriteSearchPathLine(line, workspaceRoot) {
2316
2624
  return line;
2317
2625
  return resolveToolPath(line, workspaceRoot);
2318
2626
  }
2319
- /**
2320
- * Shell stdout is mixed prose. Rewrite only tokens that are clearly relative
2321
- * paths, including `file:line` and `file:line:col`. Leave sentences, URLs, and
2322
- * status words alone.
2323
- */
2324
- function groundShellPathText(output, root) {
2325
- if (!root || !output)
2326
- return output;
2327
- return normalizeToolText(output).split("\n").map((line) => rewriteShellPathLine(line, root)).join("\n");
2328
- }
2329
- function shellPathRoot(resultMetadata, workspaceRoot) {
2330
- const workingDirectory = str(resultMetadata?.working_directory)?.trim();
2331
- if (!workingDirectory)
2332
- return workspaceRoot;
2333
- if (isAbsoluteToolPath(workingDirectory))
2334
- return workingDirectory;
2335
- return workspaceRoot ? joinToolPath(workspaceRoot, workingDirectory) : undefined;
2336
- }
2337
- function rewriteShellPathLine(line, root) {
2338
- if (!line || line.startsWith(" ") || line.startsWith("\t"))
2339
- return line;
2340
- const trimmed = line.trimEnd();
2341
- if (trimmed.includes("://") || trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
2342
- return line;
2343
- }
2344
- const located = /^(.+?):(\d+)(?::(\d+))?$/.exec(trimmed);
2345
- if (located && located[1] && isRelativePathToken(located[1])) {
2346
- const suffix = located[3] !== undefined ? `:${located[2]}:${located[3]}` : `:${located[2]}`;
2347
- return `${resolveToolPath(located[1], root)}${suffix}`;
2348
- }
2349
- if (/\s/.test(trimmed))
2350
- return line;
2351
- const header = /^(.*):$/.exec(trimmed);
2352
- if (header && header[1] && isRelativePathToken(header[1])) {
2353
- return `${resolveToolPath(header[1], root)}:`;
2354
- }
2355
- if (isRelativePathToken(trimmed))
2356
- return resolveToolPath(trimmed, root);
2357
- return line;
2358
- }
2359
2627
  const OPENCODE_GREP_LINE = /^[ \t]+Line (\d+):[ \t]?(.*)$/;
2360
2628
  /**
2361
2629
  * OpenCode 1.x and 2.0 both render grep as `Found N matches`, a `path:` header,
@@ -2538,12 +2806,36 @@ export function unwrapReadOutput(output) {
2538
2806
  // than the envelope (the envelope is exactly what Cursor echoes into writes).
2539
2807
  return raw.join("\n");
2540
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
+ }
2541
2831
  /**
2542
2832
  * Map OpenCode tool text into the agent.v1 result oneof for each exec variant.
2543
2833
  * OpenCode returns free-form text; we wrap it in the minimal success shape the
2544
2834
  * server accepts (verified against agent.v1 wire captures).
2545
2835
  */
2546
2836
  export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot, images = []) {
2837
+ if (error === "")
2838
+ error = "OpenCode tool execution failed";
2547
2839
  // Prefer the session workspace; never advertise the host process cwd (daemon
2548
2840
  // often starts in $HOME) as the path Cursor shows the model for glob/ls.
2549
2841
  const trimmedRoot = typeof workspaceRoot === "string" ? workspaceRoot.trim() : "";
@@ -2664,7 +2956,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2664
2956
  };
2665
2957
  }
2666
2958
  case "write_result": {
2667
- // Prefer the path recorded from WriteArgs / a remapped apply_patch write.
2959
+ // Prefer the path recorded from WriteArgs / a remapped patch write.
2668
2960
  // Host write output is not a `<path>` envelope, and Cursor's GetMcpTools
2669
2961
  // spill copies WriteSuccess.path into the model-visible filePath.
2670
2962
  const remappedPath = str(resultMetadata?.path);
@@ -2695,16 +2987,15 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2695
2987
  case "shell_result": {
2696
2988
  const command = str(resultMetadata?.command) ?? "";
2697
2989
  const workingDirectory = str(resultMetadata?.working_directory) ?? "";
2698
- const stdout = groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot));
2699
- if (error) {
2990
+ if (error || shellOutcome?.kind === "aborted") {
2700
2991
  return {
2701
2992
  failure: {
2702
2993
  command,
2703
2994
  working_directory: workingDirectory,
2704
- exit_code: 1,
2705
- stdout: stdout || "",
2706
- stderr: error,
2707
- aborted: false,
2995
+ exit_code: shellOutcome?.kind === "aborted" ? 0 : 1,
2996
+ stdout: output || "",
2997
+ stderr: error || "Shell execution interrupted",
2998
+ aborted: shellOutcome?.kind === "aborted",
2708
2999
  },
2709
3000
  };
2710
3001
  }
@@ -2723,7 +3014,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2723
3014
  command: shellOutcome.command || command,
2724
3015
  working_directory: shellOutcome.workingDirectory || workingDirectory,
2725
3016
  exit_code: 0,
2726
- stdout: stdout,
3017
+ stdout: output,
2727
3018
  shell_id: shellOutcome.shellId,
2728
3019
  pid: shellOutcome.pid,
2729
3020
  ms_to_wait: shellOutcome.msToWait,
@@ -2741,14 +3032,11 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2741
3032
  command,
2742
3033
  working_directory: workingDirectory,
2743
3034
  exit_code: exitCode,
2744
- stdout: stdout,
3035
+ stdout: output,
2745
3036
  },
2746
3037
  };
2747
3038
  }
2748
3039
  case "pi_bash_result":
2749
- if (error)
2750
- return { error: { error } };
2751
- return { success: { output: groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot)) } };
2752
3040
  case "pi_edit_result":
2753
3041
  if (error)
2754
3042
  return { error: { error } };
@@ -2766,13 +3054,13 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2766
3054
  }
2767
3055
  case "delete_result":
2768
3056
  if (error)
2769
- return { error: { path: "", error } };
2770
- 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: "" } };
2771
3059
  case "background_shell_spawn_result": {
2772
3060
  const command = str(resultMetadata?.command) ?? "";
2773
3061
  const workingDirectory = str(resultMetadata?.working_directory) ?? "";
2774
- if (error)
2775
- 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" } };
2776
3064
  // Prefer the structured outcome captured by the plugin after-hook; markers
2777
3065
  // are already stripped from the stored/rendered OpenCode output by then.
2778
3066
  if (shellOutcome?.kind === "backgrounded") {
@@ -2786,8 +3074,10 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2786
3074
  };
2787
3075
  }
2788
3076
  // Fallback when no plugin hook ran: parse the private spawn marker inline.
2789
- const match = new RegExp(`${BACKGROUND_SHELL_MARKER}(\\d+):([^\\r\\n]+)`).exec(output);
2790
- 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;
2791
3081
  if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffff_ffff) {
2792
3082
  return {
2793
3083
  error: {
@@ -2799,7 +3089,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2799
3089
  }
2800
3090
  return {
2801
3091
  success: {
2802
- shell_id: pid,
3092
+ shell_id: shellId,
2803
3093
  command,
2804
3094
  working_directory: workingDirectory,
2805
3095
  pid,
@@ -2808,7 +3098,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2808
3098
  }
2809
3099
  case "ls_result": {
2810
3100
  if (error)
2811
- return { error: { path: "", error } };
3101
+ return { error: { path: resolveToolPath(str(resultMetadata?.path) ?? "", resultRoot), error } };
2812
3102
  const listing = parseOpenCode2DirectoryListing(output, resultRoot);
2813
3103
  const rootPath = listing?.directory || resultRoot || "";
2814
3104
  const entries = listing ? listing.entries : extractGroundedPaths(output, resultRoot);